From 7c38fd8102be7a8a36adf63e8049afd265acda46 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 15:30:35 +0800 Subject: [PATCH 001/429] fix: deliver images reliably with steer and follow-up messages A steer or follow-up accepted while a turn is closing is now claimed by a fresh turn at the driver's clean exit instead of stranding in the inbox; cancellation and pre-step rejection still park accepted work. Continuable subagent follow-ups accept image parts: the wire is upload-shaped, the Host admits and persists each batch before inbox acceptance, and delivery is refused when the child model declines image input. The queue dock renders durable image thumbnails instead of an [image] text marker. Fixes #3186 --- ...27-steer-followup-image-delivery.i18n.yaml | 6 + ...026-08-27-steer-followup-image-delivery.md | 41 +++++ ...-08-27-steer-followup-image-delivery.zh.md | 41 +++++ apps/web/tests/queue-image.e2e.ts | 147 ++++++++++++++++++ apps/web/tsconfig.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 8 +- docs/subsystems/subagent.zh.md | 8 +- .../src/client/contract/session.ts | 4 +- .../src/client/sessions/queue-mirror.ts | 3 + .../src/client/sessions/session.ts | 31 ++-- .../api/session-controller/src/commands.ts | 23 +-- packages/api/session-controller/src/types.ts | 12 +- .../tests/queue-store.client.spec.ts | 6 +- .../tests/session-models.host.spec.ts | 52 +++++++ .../tests/session.client.spec.ts | 26 ++++ packages/attachment/attachment/src/index.ts | 1 + packages/attachment/attachment/src/types.ts | 15 ++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/image-labels.ts | 1 - .../ui-conversation/src/client/locales.ts | 4 +- .../src/client/queue/QueueDock.module.css | 15 ++ .../src/client/queue/QueueDock.tsx | 63 +++++++- .../tests/image-labels.client.spec.ts | 1 - .../tests/queue-dock.client.spec.tsx | 64 +++++++- packages/core/agent-loop/src/agent.ts | 49 +++++- packages/core/agent-loop/tests/loop.spec.ts | 80 ++++++++++ .../src/client/api-catalog.ts | 4 - .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/llm/llm/src/content.ts | 28 +++- packages/llm/llm/tests/content.spec.ts | 31 ++++ packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/package.json | 2 + .../subagent/subagent/src/continuation.ts | 41 ++++- .../subagent/subagent/src/control-types.ts | 12 +- packages/subagent/subagent/src/control.ts | 6 + packages/subagent/subagent/src/index.ts | 13 +- .../subagent/tests/continuation.spec.ts | 68 ++++++++ .../subagent/subagent/tests/control.spec.ts | 67 +++++++- pnpm-lock.yaml | 3 + .../web/queued-image/delivered.expected.md | 82 ++++++++++ snapshots/web/queued-image/queued.expected.md | 42 +++++ snapshots/web/queued-image/snapshot.yml | 9 ++ tsconfig.host.json | 1 + 51 files changed, 1037 insertions(+), 108 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md create mode 100644 apps/web/tests/queue-image.e2e.ts create mode 100644 snapshots/web/queued-image/delivered.expected.md create mode 100644 snapshots/web/queued-image/queued.expected.md create mode 100644 snapshots/web/queued-image/snapshot.yml diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml new file mode 100644 index 0000000000..8f4d10d9e8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.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-27-steer-followup-image-delivery.md +2026-08-27-steer-followup-image-delivery.md: fb93612254381b589c8adfe44e46c51dfe232c3e +2026-08-27-steer-followup-image-delivery.zh.md: 35284aba89a7df5da5d472f078d2c2d3ea2e307d diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md new file mode 100644 index 0000000000..fb93612254 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md @@ -0,0 +1,41 @@ +# Agent Note: Steer and follow-up image delivery + +Status: implemented + +English | [中文](2026-08-27-steer-followup-image-delivery.zh.md) + +## Problem + +Images submitted while an agent is running did not reliably reach the model context (#3186), for three independent reasons. + +First, a steer or follow-up spliced into a live driver latched no wake: the live driver was expected to claim it, but a turn that finished or failed between the splice and the claim exited without re-checking, stranding the accepted message until an unrelated waking send. Image admission widens this window because the Host awaits attachment normalization before `agent.steer()`/`agent.followup()` runs. + +Second, continuable-subagent follow-ups rejected images in the Client (`SUBAGENT_IMAGE_UNSUPPORTED`) before any RPC, and stripped image parts from the text-only call. The Host route had no admission at all, and its wire content was `ContentBlock[]`, so lifting the Client rejection alone would have let a browser cite any `attachmentId` it never uploaded. + +Third, the browser queue projection reduced a queued image to the text `[image]` even though the durable reference was already present and readable through the session attachment authorization. + +## Decision + +**Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule. + +**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]`, whose single home moved from `dsh-api-session-controller` to `dsh-attachment`; the shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. + +**Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072). + +## Alternatives considered + +**Re-wake on every driver exit with pending input.** Rejected: it breaks the deliberate parking semantics of `cancel({ keepInbox: true })` and pre-step rejection, and a pre-commit `turn/start` failure would re-enter a hot loop because the failing turn never claims the message. + +**Latch `wakeRequested` for sends to a live driver.** Rejected: the latch is not pruned on claim, so a claimed steer plus leftover injected context would open a context-only turn at exit, violating the rule that injected context waits for a waking message. + +**Keep the wire content `ContentBlock[]` and admit refs on the Host.** Rejected: a reference-shaped wire lets a Client fabricate `attachmentId` citations; an upload-shaped wire makes Host admission the only way an attachment reference can exist in a child message. + +**Check child image capability in `SubagentRuntime.prompt`.** Rejected: the route may address a cold child whose agent does not exist yet; the continuation manager sees the live or freshly materialized agent in both arms and inside the per-child delivery lock, so the check cannot race a concurrent delivery. + +## Testing + +Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, queue thumbnails (load, failure placeholder, unmount), and the image-free preview. + +## Consequences + +A steer or follow-up accepted during a turn's final microtasks is now delivered by a fresh turn instead of hanging in the inbox, while user cancellation still parks pending work — delivery after a stop remains an explicit next waking send. The subagent package now depends on `dsh-attachment` and reads `ctx.llm` optionally. Images persisted by a batch whose delivery is later refused stay as unreachable content-addressed objects under the existing retention rules. Queue thumbnails add one authorized attachment read per queued image, shared with the transcript cache. diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md new file mode 100644 index 0000000000..35284aba89 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md @@ -0,0 +1,41 @@ +# Agent Note: steer 与 follow-up 的图片投递 + +Status: implemented + +[English](2026-08-27-steer-followup-image-delivery.md) | 中文 + +## Problem + +agent 运行期间提交的图片没有可靠进入模型上下文(#3186),原因有三个,彼此独立。 + +第一,splice 进在线 driver 的 steer 或 follow-up 不会锁存唤醒:预期由在线 driver 自行认领,但轮次在 splice 与认领之间正常结束或失败时,退出路径不再复查,已接受的消息就滞留到下一次无关的唤醒发送。图片准入放大了这个窗口,因为 Host 在执行 `agent.steer()`/`agent.followup()` 之前要先等待附件规范化完成。 + +第二,可继续子代理的 follow-up 在客户端就拒绝图片(`SUBAGENT_IMAGE_UNSUPPORTED`),并把图片部分从纯文本调用中剥掉。Host 路由完全没有准入,wire 内容又是 `ContentBlock[]`,单独放开客户端拒绝会允许浏览器引用任何它从未上传过的 `attachmentId`。 + +第三,浏览器队列投影把已排队的图片折叠成文本 `[image]`,尽管持久化引用已经存在,并且可以通过会话附件授权读取。 + +## Decision + +**轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。 + +**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`,该类型的唯一定义处从 `dsh-api-session-controller` 移到 `dsh-attachment`;共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 + +**队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。 + +## Alternatives considered + +**任何 driver 退出都在有滞留输入时重新拉起。** 拒绝:这破坏 `cancel({ keepInbox: true })` 与 pre-step 拒绝的刻意停放语义,而且 `turn/start` 提交前失败的轮次永远不会认领消息,会进入热循环。 + +**对发给在线 driver 的发送也锁存 `wakeRequested`。** 拒绝:锁存不随认领清除,被认领的 steer 加上剩余的注入上下文会在退出时开出一个只有上下文的轮次,违反注入上下文必须等待唤醒消息的规则。 + +**wire 内容保持 `ContentBlock[]`,由 Host 准入引用。** 拒绝:引用形态的 wire 允许客户端伪造 `attachmentId`;上传形态的 wire 使 Host 准入成为子级消息里附件引用的唯一来源。 + +**在 `SubagentRuntime.prompt` 里做子级图片能力检查。** 拒绝:该路由可能寻址冷的子级,其 agent 尚不存在;continuation 管理器在两条分支里都拿得到在线或刚物化的 agent,并且处于逐子级投递锁内,检查不会与并发投递竞态。 + +## Testing + +agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、队列缩略图(加载、失败占位、卸载)与不含图片的预览。 + +## Consequences + +在轮次最后几个微任务里被接受的 steer 或 follow-up 现在由新轮次投递,而不是挂在 inbox 里;用户取消仍然停放待处理工作,停止之后的投递依旧需要一次显式的唤醒发送。subagent 包新增对 `dsh-attachment` 的依赖,并可选读取 `ctx.llm`。整批持久化后投递被拒绝的图片按现有保留规则保持为不可达的内容寻址对象。队列缩略图对每张排队图片增加一次授权附件读取,与会话记录缓存共享。 diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts new file mode 100644 index 0000000000..e5a4794d82 --- /dev/null +++ b/apps/web/tests/queue-image.e2e.ts @@ -0,0 +1,147 @@ +// Keyless browser coverage for image attachments submitted while a turn is +// running, through the shipped Web composition and real HTTP/SSE wire. A +// text-plus-image submission queues as one occurrence whose dock row renders +// the durable thumbnail, survives a stop as parked work, and delivers as the +// next turn's user message with its image intact — while the session log holds +// only durable attachment references, never base64. +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queued-image', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url)) +const PNG = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url)) +const QUEUED_EXPECTED = join(SNAPSHOT_DIR, 'queued.expected.md') +const DELIVERED_EXPECTED = join(SNAPSHOT_DIR, 'delivered.expected.md') +const MODE = webSnapshotMode() + +const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.' +const QUEUED_TEXT = 'Compare with this screenshot' + +/** Paste one real PNG into the composer through a genuine clipboard event. */ +async function pasteImage(page: Page, bytes: Uint8Array): Promise { + await page.locator('[data-composer-input]').first().evaluate((surface, data) => { + const transfer = new DataTransfer() + transfer.items.add(new File([new Uint8Array(data)], 'queued.png', { type: 'image/png' })) + surface.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: transfer, bubbles: true, cancelable: true, + })) + }, [...bytes]) +} + +describe('web e2e: queued image submission', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let overrideDir: string | undefined + + afterEach(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + browser = undefined + const closing = scaffold + scaffold = undefined + await closing?.close().catch((error: unknown) => failures.push(error)) + if (overrideDir !== undefined) { + await rm(overrideDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + overrideDir = undefined + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'queued-image teardown failed') + }) + + it.skipIf(MODE === 'record')('queues a text-plus-image submission with a thumbnail and delivers it as the next turn', async () => { + overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queued-image-')) + const readyFile = join(overrideDir, '.hang-ready') + const overridePath = join(overrideDir, 'replay.override.json') + const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) + expect(recorded).toHaveLength(1) + const replay: ReplayEntry[] = [ + { kind: 'hang', readyFile }, + recorded[0]!, + recorded[0]!, + ] + await writeFile(overridePath, JSON.stringify(replay)) + + const sessionEvents: SessionEvent[] = [] + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + onTestFailed(() => saveFailureShot(page, 'web-e2e-queued-image')) + + const input = page.locator('[data-composer-input]').first() + const firstSettled = scaffold.whenTurnSettled() + await input.fill(ACTIVE_PROMPT) + await input.press('Enter') + await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) + + // A just-submitted composer is read-only for the prompt round-trip. + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) + await pasteImage(page, await readFile(PNG)) + await page.getByRole('img', { name: 'queued.png' }).waitFor({ timeout: 10_000 }) + await input.fill(QUEUED_TEXT) + await input.press('Enter') + + // The queued row renders the durable thumbnail beside the text preview. + const dockThumb = page.locator('[data-queue-dock] img[alt="Queued message image"]') + await dockThumb.waitFor({ timeout: 15_000 }) + await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/) + await page.getByText(QUEUED_TEXT, { exact: true }).waitFor() + const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE) + + // Stop parks the accepted queue; the next waking send delivers the image + // message first (FIFO), then its own text as the following turn. + await page.getByRole('button', { name: 'Stop generating' }).click() + await firstSettled + await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0) + await dockThumb.waitFor({ timeout: 10_000 }) + + const settled = scaffold.whenTurnSettled() + await input.fill('Continue with the queued comparison') + await input.press('Enter') + await settled + + // The delivered user message renders its image in Chat from the durable + // reference, and the dock row is gone. + await expect.poll( + () => page.locator('[data-queue-dock]').count(), + { timeout: 15_000 }, + ).toBe(0) + const chatImage = page.locator('[class*="userRow"] img') + await chatImage.first().waitFor({ timeout: 15_000 }) + const deliveredSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DELIVERED_EXPECTED, deliveredSnapshot, MODE) + + // Model-visible means logged: the delivered message carries the durable + // reference (never base64), in the composer's canonical images-then-text order. + const delivered = sessionEvents.find(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'image')) + expect(delivered?.type === 'user/message' && delivered.data.content.map(block => block.type)).toEqual(['image', 'text']) + const imageBlock = delivered?.type === 'user/message' + ? delivered.data.content.find(block => block.type === 'image') + : undefined + expect(imageBlock?.type === 'image' && imageBlock.attachment.name).toBe('queued.png') + expect(JSON.stringify(sessionEvents)).not.toContain('base64') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f261db5614..03f5a08a75 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -73,6 +73,7 @@ "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", + "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5169a54f8b..9c98358b90 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/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 docs/architecture.md -architecture.md: 20d03c079fa8e1f73f733992b6e938f0f539a60f -architecture.zh.md: 5448036ad6e11902e32f89d0a65be99230e27e02 +architecture.md: 5717bf62b0967ff860dee9db5265d8dd56242a1d +architecture.zh.md: 279d8c4cc040bc18f7adfd1ae1b782447c3333d6 diff --git a/docs/architecture.md b/docs/architecture.md index 20d03c079f..5717bf62b0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ turn/end `turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. -Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. +Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. A waking message that arrives while a turn is closing is claimed by a fresh turn at the driver's clean exit, while cancellation and step rejection park accepted-but-unclaimed input until the next waking send. `agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 5448036ad6..279d8c4cc0 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -98,7 +98,7 @@ turn/end `turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 -输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 +输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。在轮次收尾期间到达的唤醒消息会在驱动器干净退出时由新轮次认领;取消与步骤拒绝则把已接受但未认领的输入停放到下一次唤醒发送。 `agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f4414983b1..8aa69ce9a2 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f -subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468 +subagent.md: 03d32139157b2f2c2bf9b58ce8f0ea36d2187a66 +subagent.zh.md: 61bb2f32d1eb85a894dfbd40fc259fbdab8924de diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index f854711f11..03d3213915 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -669,13 +669,15 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 960d9b099d..61bb2f32d1 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -673,13 +673,15 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 6ac4182bb9..fc97dbff8e 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,12 +7,12 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, ImageAttachmentRef, PromptContentPart } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction } from '../../types.ts' +import type { QueueAction } from '../../types.ts' import type { ClientResult } from './result.ts' import type { SessionSnapshot } from './snapshot.ts' diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 209349af2c..8ae4539607 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -5,8 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts' const QUEUE_PREVIEW_CHARS = 200 +// Image blocks are excluded: queue presentation renders them as thumbnails +// from `content`, so the text preview covers only what has no visual form. function previewOf(content: readonly ContentBlock[]): string { const flat = content + .filter(block => block.type !== 'image') .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) .join(' ').replace(/\s+/g, ' ').trim() const chars = Array.from(flat) diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index db7cf9a997..4645d7d334 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -208,28 +208,15 @@ export class Session implements SessionFace { }, } } else { - if (content.some(part => part.type === 'image')) { - result = { - ok: false, - error: { - code: 'attachment-error', - message: 'Image input is unavailable for subagent continuations.', - details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, - }, - } - } else { - const routed = toSessionResult(await this.remote.subagents.prompt({ - requestId: randomUUID() as SessionRequestId, - parentSessionId: this.address.parentSessionId, - childSessionId: this.address.childSessionId, - mode: this.address.mode, - content: content.flatMap(part => part.type === 'text' - ? [{ type: 'text' as const, text: part.text }] - : []), - clientTimeZone: resolvedClientTimeZone(), - }, signal)) - result = routed.ok ? { ok: true, value: { accepted: true } } : routed - } + const routed = toSessionResult(await this.remote.subagents.prompt({ + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: this.address.mode, + content, + clientTimeZone: resolvedClientTimeZone(), + }, signal)) + result = routed.ok ? { ok: true, value: { accepted: true } } : routed } } catch (error) { result = transportResult(error) diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..be2b8304e2 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,12 +4,12 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' -import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { - ReasoningEffortId, createUserMessage, freezeMessage, + ReasoningEffortId, createUserMessage, durablePromptContent, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -319,7 +319,7 @@ export class SessionCommandController { ) } } - const content = await durablePromptContent(this.ctx, request.content) + const content = await durablePromptContent(this.ctx.attachments, request.content) const message: UserMessage = createUserMessage({ content, source }) if (request.mode === 'steer') agent.steer(message) else agent.followup(message) @@ -511,21 +511,6 @@ function reject(code: string, message: string, details: object): never { throw new TypertRemoteFailure({ code, message, details }) } -async function durablePromptContent( - ctx: Context, - content: readonly SessionPromptRequest['content'][number][], -): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) -} - function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c045e00707..c58eb419ca 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, + AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, PromptContentPart, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -67,15 +67,7 @@ export interface SessionProjectionBaseline { export type SessionProjectionValues = Partial & Readonly> -/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ -export type PromptContentPart = - | { readonly type: 'text'; readonly text: string } - | { - readonly type: 'image' - readonly mediaType: ImageMediaType - readonly data: string - readonly name?: string - } +export type { PromptContentPart } from '@deepseek-ai/dsh-attachment' /** Complete model selection for one Session. */ export interface ModelSelection { diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 6fc045cf70..856a861d5a 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -73,7 +73,7 @@ describe('Session queue snapshot intake', () => { ]) }) - it('marks mixed-content messages non-editable while retaining their preview', () => { + it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => { const session = makeSession() session.handleControlFrame(queueFrame([{ id: 'q-image', @@ -86,7 +86,9 @@ describe('Session queue snapshot intake', () => { { id: 'q-image', placement: 'queued', content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], - preview: 'hi [image]', text: null, + // Image blocks render as thumbnails from `content`, so the preview + // carries only the text; non-image foreign blocks keep their marker. + preview: 'hi', text: null, }, ]) }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index d203585459..b38ee0f94c 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -231,6 +231,58 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('delivers an admitted image batch through steer with the same ordered content as queue', async () => { + const { ctx, agent, sessionId } = await harness() + const attachments = { + imageLimits: { + maxImageBytes: 4, + maxImagesPerMessage: 2, + maxMessageImageBytes: 4, + maxImagePixels: 4, + maxImageDimension: 2000, + mediaTypes: ['image/png'], + }, + validateImage: vi.fn(() => Promise.resolve()), + 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 }, + })), + } + ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) + const steer = vi.fn() + const followup = vi.fn() + Object.assign(agent, { steer, followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + + const result = await remote.prompt(promptRequest({ + sessionId, + mode: 'steer' as const, + content: [ + { type: 'text' as const, text: 'look at this' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'mid-turn.png' }, + ], + })) + expect(result.ok).toBe(true) + expect(followup).not.toHaveBeenCalled() + expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { type: 'text', text: 'look at this' }, + { + type: 'image', + attachment: { + attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'mid-turn.png', + }, + }, + ]) + await ctx.fiber.dispose() + }) + 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) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 150e6813d1..ad436779e1 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -281,6 +281,32 @@ describe('prompt and cancel errors', () => { }) }) + it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const content = [ + { type: 'text' as const, text: '看这张图' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=', name: 'shot.png' }, + ] + const prompted = await session.prompt(content, 'queue') + + expect(prompted).toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('subagents.prompt')).toEqual([ + { + requestId: expect.any(String) as unknown as string, + parentSessionId: PARENT, childSessionId: SID, + mode: 'continuable', + content, + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ]) + expect(session.getSnapshot().promptError).toBeNull() + }) + it('lands an interrupt business failure in promptError with op=stop', async () => { const api = new FakeApiClient() api.onSubagentInterrupt = () => Promise.resolve(remoteErr({ diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 4ee001b86c..1c0f76b9e0 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -23,6 +23,7 @@ export type { ImageAttachmentRef, ImageRequestPolicy, ImageMediaType, + PromptContentPart, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 046444cd76..64c199eaf1 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -52,6 +52,21 @@ export interface EncodedImageAttachment { name?: string } +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `admitEncodedImages` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +export type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } + /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { data: Uint8Array diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d7a0420bec..63e2a89061 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: 188a4461c1ab46e8be4f96c402c4707b1c66c415 -README.zh.md: 63356b04ef07ddd8c037d46d0ad105ed01afda80 +README.md: 3a51c20caaa78f1e638d5117a9153112ce530fe2 +README.zh.md: be5b5396620566b356cc51aedc3fed21779cccd9 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 188a4461c1..3a51c20caa 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ The package registers the optional-Session `conversation` shell, strict Session 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. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show each durable image part as a thumbnail resolved through the session image URL cache, while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 63356b04ef..be5b539662 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把每个持久化图片部分经会话图片 URL 缓存解析为缩略图展示,编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index 6d4ee7a130..d2333c324c 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -31,7 +31,6 @@ export function attachmentErrorText( ): string { switch (reason) { case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') - case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported') case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') case 'IMAGE_DIMENSION_TOO_LARGE': if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension }) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 480f73764b..e8509b2c6f 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -45,7 +45,6 @@ export const zh = { 'image.tooManyPixels': '图片分辨率过大,请压缩后重试', 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', - 'image.subagentUnsupported': '子智能体会话暂不支持图片', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -126,6 +125,7 @@ export const zh = { 'web.contentTruncated': '内容已截断', 'details.running': '运行中…', 'queue.count': '{n} 条排队消息', + 'queue.image': '排队消息图片', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', 'queue.save': '保存排队消息', @@ -190,7 +190,6 @@ export const en = { 'image.tooManyPixels': 'Image resolution is too high; compress it and try again', 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', - 'image.subagentUnsupported': 'Subagent sessions do not support images yet', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', 'context.aria': '{percent} of context used', 'context.used': 'of context used', @@ -271,6 +270,7 @@ export const en = { 'web.contentTruncated': 'Content truncated', 'details.running': 'Running…', 'queue.count': '{n} queued messages', + 'queue.image': 'Queued message image', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', 'queue.save': 'Save queued message', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index eca51941ca..81ea205b5e 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -122,6 +122,21 @@ box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1); } +.thumbs { + display: flex; + flex: none; + gap: 4px; +} + +.thumb { + width: 24px; + height: 24px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 4px; + background: var(--dsw-alias-bg-base); + object-fit: cover; +} + .preview, .editor { flex: 1 1 auto; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index aadedd7d29..a42b8cea68 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,12 +1,13 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { QueueAction, QueueItemId } from '../contract/queue.ts' +import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' import { NS } from '../locales.ts' import css from './QueueDock.module.css' @@ -14,6 +15,43 @@ import css from './QueueDock.module.css' export interface QueueDockInjected { updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise notify: (level: 'info' | 'error', text: string) => void + /** Resolve one durable queued image into a session-scoped browser URL. */ + loadImage: (attachment: ImageAttachmentRef) => Promise +} + +/** + * Durable references carried by one queued row. Queue frames are wire data + * despite their typed face, so an image block without a reference is skipped + * rather than trusted. + * @param content - the row's wire content blocks. + * @returns the row's durable image references in block order. + */ +function queueImageRefs(content: QueueRow['content']): ImageAttachmentRef[] { + return content.flatMap((block) => { + if (block.type !== 'image') return [] + const { attachment } = block as { attachment?: ImageAttachmentRef } + return attachment === undefined ? [] : [attachment] + }) +} + +/** One durable queued image as a fixed-size thumbnail; a load failure keeps the empty placeholder. */ +function QueueThumb({ attachment, loadImage, label }: { + attachment: ImageAttachmentRef + loadImage: QueueDockInjected['loadImage'] + label: string +}) { + const [url, setUrl] = useState(null) + useEffect(() => { + let alive = true + loadImage(attachment).then( + (resolved) => { if (alive) setUrl(resolved) }, + () => { /* placeholder retained; the durable transcript surfaces read errors */ }, + ) + return () => { alive = false } + }, [attachment, loadImage]) + return url === null + ? + : {label} } /** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */ @@ -23,7 +61,7 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock * Queue strip: one item renders directly; multiple items default to a * collapsible count header; an empty queue renders nothing. */ -export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { +export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) { const inbox = useSession(s => s.queue) const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) const running = useSession(s => s.running) @@ -114,7 +152,23 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps }} /> ) - : {projectUserText(row.preview, [])}} + : ( + <> + {queueImageRefs(row.content).length > 0 && ( + + {queueImageRefs(row.content).map((attachment, index) => ( + + ))} + + )} + {projectUserText(row.preview, [])} + + )} {queueMutable &&
{editing?.id === row.id ? ( @@ -210,7 +264,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps /** Registers queue actions backed by the session-scoped conversation service. */ export const queueDockEntry = { name: 'conversation-queue-dock', - inject: ['slots', 'conversation', 'sessions'], + inject: ['slots', 'conversation', 'sessions', 'uiConversation'], apply(ctx: Context): void { ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', @@ -225,6 +279,7 @@ export const queueDockEntry = { return { updateQueue: (itemId, action) => conversation.updateQueue(itemId, action), notify: (level, text) => { conversation.input.for(actx).notify(level, text) }, + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), } }, }, QueueDock)) diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.ts b/packages/client/ui-conversation/tests/image-labels.client.spec.ts index 8e1ff4409d..0244c134db 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.ts +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.ts @@ -24,7 +24,6 @@ describe('attachment rejection copy', () => { it('maps user-solvable reasons to limit-naming copy', () => { expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型') - expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片') expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试') expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 6a30ed4d8a..75701f7076 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -87,10 +87,26 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial input: INPUT_STATE, updateQueue: vi.fn(() => Promise.resolve()), notify: vi.fn(), + loadImage: vi.fn(() => Promise.resolve('blob:unused')), ...injected, } } +/** One queued row carrying a durable image reference (plus optional leading text). */ +function imageRow(id: string, refId: string, text = ''): QueuedMessage { + return { + id: iid(id), messageId: `message-${id}` as never, placement: 'queued', + content: [ + ...text === '' ? [] : [{ type: 'text' as const, text }], + { + type: 'image', + attachment: { attachmentId: refId, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + } as never, + ], + preview: text, text: null, + } +} + describe('QueueDock', () => { it('renders null while the queue is empty', () => { const snap = snapshotWith([]) @@ -223,6 +239,52 @@ describe('QueueDock', () => { .toBe('包含非文本内容,暂不支持编辑') }) + it('renders queued image thumbnails from durable references beside the text preview', async () => { + const loadImage = vi.fn(() => Promise.resolve('blob:thumb-1')) + const snap = snapshotWith([imageRow('i-img', 'att-9', '带图消息')]) + const source = liveSession(snap) + const { container } = render( + , + ) + + await waitFor(() => { + expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:thumb-1') + }) + expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ attachmentId: 'att-9' })) + expect(container.querySelector('img')?.getAttribute('alt')).toBe('排队消息图片') + expect(container.querySelector('li')?.textContent).toBe('带图消息') + }) + + it('keeps the empty thumbnail placeholder when the image read fails', async () => { + const loadImage = vi.fn(() => Promise.reject(new Error('read denied'))) + const snap = snapshotWith([imageRow('i-broken', 'att-x')]) + const source = liveSession(snap) + const { container } = render( + , + ) + + await act(async () => { await Promise.resolve() }) + expect(loadImage).toHaveBeenCalled() + expect(container.querySelector('img')).toBeNull() + }) + + it('ignores a thumbnail resolution landing after unmount', async () => { + let resolveUrl: ((url: string) => void) | undefined + const loadImage = vi.fn(() => new Promise((resolve) => { resolveUrl = resolve })) + const snap = snapshotWith([imageRow('i-late', 'att-late')]) + const source = liveSession(snap) + const { unmount } = render( + , + ) + + unmount() + await act(async () => { + resolveUrl?.('blob:late') + await Promise.resolve() + }) + expect(loadImage).toHaveBeenCalledTimes(1) + }) + it('edits text inline with save and cancel controls, then saves with the same item identity', async () => { const snap = snapshotWith([row('i-edit', 'before')]) const source = liveSession(snap) @@ -388,7 +450,7 @@ describe('QueueDock', () => { it('registers as the terminal composer-context entry', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') - expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) + expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions', 'uiConversation']) const register = vi.fn(() => () => undefined) const inject = vi.fn((_name: string, callback: () => () => void) => callback()) queueDockEntry.apply({ slots: { inject, register } } as never) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 0d3af9663b..9bac17bff0 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -70,6 +70,17 @@ export class ReactLoopAgent implements Agent { readonly inbox: Inbox private phase: Phase private activityDone: Promise = Promise.resolve() + /** + * Identities of waking sends still awaiting a claim. Claim and discard + * notifications prune the set, and {@link cancel} clears it because a + * cancellation parks accepted-but-unclaimed input for a later waking send. + * A non-empty set at driver exit therefore means a steer or follow-up lost + * the race with a normally or erroneously closing turn, and the exit must + * start a fresh driver to deliver it. Injected context never enters the + * set, so it keeps waiting for a waking message instead of opening a turn + * by itself. + */ + private readonly pendingWakes = new Set() /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */ readonly scope: Scope @@ -93,8 +104,14 @@ export class ReactLoopAgent implements Agent { this.dispatch = agentEvents(loopCtx, this) this.inbox = new Inbox(session, { inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) }, - discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, - claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, + discarded: (message) => { + this.pendingWakes.delete(message.id) + this.dispatch.emit('agent/inbox/discarded', { message }) + }, + claimed: (message, turn) => { + this.pendingWakes.delete(message.id) + this.dispatch.emit('agent/inbox/claimed', { message, turn }) + }, }) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } @@ -122,7 +139,15 @@ export class ReactLoopAgent implements Agent { // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target - this.inbox.splice(resolvedTarget, Infinity, 0, [message]) + // Registered before the splice so a reentrant discard inside the splice + // dispatch still prunes it; a refused splice never leaves an entry behind. + if (wakeup) this.pendingWakes.add(message.id) + try { + this.inbox.splice(resolvedTarget, Infinity, 0, [message]) + } catch (error: unknown) { + this.pendingWakes.delete(message.id) + throw error + } if (wakeup) this.wakeDriver(wakingAfterAbort) } @@ -143,6 +168,10 @@ export class ReactLoopAgent implements Agent { this.inbox.clear() if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } + // Cancellation consumes outstanding wakes: kept inbox work parks until + // the next waking send resumes the queue, and a cleared inbox has nothing + // left to deliver. + this.pendingWakes.clear() if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } @@ -215,8 +244,14 @@ export class ReactLoopAgent implements Agent { } private async kick(): Promise { + // Set only when the turn loop returns without throwing: an abort or driver + // failure parks unclaimed waking input for the next waking send, while a + // clean exit must deliver a steer or follow-up that lost the race with the + // closing turn (its send saw a live driver, so no wake was latched). + let cleanExit = false try { while (await this.turn()) {} + cleanExit = true } catch (_error) { // Reported failures and cancellation are contained at the driver boundary. } finally { @@ -224,7 +259,9 @@ export class ReactLoopAgent implements Agent { if (this.phase.kind === 'running') { const { turn, wakeRequested } = this.phase this.setPhase({ kind: 'idle', lastTurn: turn }) - if (wakeRequested && this.inbox.hasPending) this.wakeDriver() + if ((wakeRequested || (cleanExit && this.pendingWakes.size > 0)) && this.inbox.hasPending) { + this.wakeDriver() + } } } } @@ -272,6 +309,10 @@ export class ReactLoopAgent implements Agent { const step = phase.step + 1 const decision = await this.preStep(target, { turn, step }) if (decision.kind === 'reject') { + // The rejecting listener owns resumption: input staged behind the + // rejected claim parks until the next waking send, exactly like a + // cancellation, instead of being re-offered to the same policy. + this.pendingWakes.clear() turnEnds = { kind: 'blocked' } return false } diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 487fc60181..3ca29d6450 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1530,3 +1530,83 @@ describe('agent loop', () => { expect(replayed.events.at(-1)?.type).toBe('session/end-seed') }) }) + +describe('closing-turn wake races', () => { + /** + * Schedule one send in the closing turn's final microtask window: the + * synchronous turn/end dispatch queues the microtask before the driver's + * exit continuation, so it lands after the final inbox check and before the + * driver boundary — the race a live send cannot latch a wake for. + */ + function sendOnTurnEnd(ctx: Context, agent: Agent, deliver: () => void): void { + const dispose = ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/end') return + dispose() + queueMicrotask(deliver) + }) + } + + it('delivers a steer that lands while a clean turn is closing', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('closing-steer'), { provider: 'mock', model: 'mock' }) + sendOnTurnEnd(ctx, agent, () => { + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steer' }], source: { kind: 'user' } })) + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['first', 'late steer']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.hasPending).toBe(false) + }) + + it('delivers a follow-up that lands while a clean turn is closing', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('closing-followup'), { provider: 'mock', model: 'mock' }) + sendOnTurnEnd(ctx, agent, () => { send(agent, 'late follow-up') }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['first', 'late follow-up']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.hasPending).toBe(false) + }) + + it('leaves injected context parked when a clean turn closes without a waking message', async () => { + const adapter = new MockAdapter([textResponse('only')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('closing-inject'), { provider: 'mock', model: 'mock' }) + sendOnTurnEnd(ctx, agent, () => { + agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'user' } })) + }) + + send(agent, 'only') + await waitForIdle(ctx, agent) + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['only']) + expect(adapter.requests).toHaveLength(1) + expect(agent.inbox.nextStep).toHaveLength(1) + }) + + it('a refused duplicate splice keeps the parked injection and registers no wake', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('duplicate-splice'), { provider: 'mock', model: 'mock' }) + const message = createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'user' } }) + agent.inject(message) + + expect(() => { agent.steer(message) }).toThrow(`message "${message.id}" is already pending`) + await agent.whenIdle() + + expect(agent.inbox.nextStep).toHaveLength(1) + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(0) + }) +}) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 7dd28e586b..c5317882b4 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -606,10 +606,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionsFace', declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot;\n}', }, - { - name: 'PromptContentPart', - declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', - }, { name: 'PromptError', declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: ClientFailure;\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5412e025cd..cdd4de1213 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2168,10 +2168,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise', - description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call.', + description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call. Image parts are admitted and persisted through the attachment store before delivery, and the child\'s model must accept image input.', parameters: [{ name: 'request', description: 'durable address, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }], returns: 'the accepted message\'s inbox identity.', - throws: ['{TypertRemoteFailure} `bad-request`, `invalid-time-zone`, `subagent-parent-unavailable`, `subagent-not-resumable`, `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or `internal`.'], + throws: ['{TypertRemoteFailure} `bad-request`, `invalid-time-zone`, `attachment-error`, `subagent-parent-unavailable`, `subagent-not-resumable`, `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or `internal`.'], }, { signature: '@Remote(\'interruptByParent\') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\', ): SubagentInterruptReceipt', @@ -5228,7 +5228,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentPromptRequest', - declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: ContentBlock[];\n readonly clientTimeZone?: string;\n}', + declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: readonly PromptContentPart[];\n readonly clientTimeZone?: string;\n}', }, { name: 'SubagentPromptRequestId', diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 43c392a641..faac6249af 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,7 +2,8 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, PromptContentPart, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { admitEncodedImages } from '@deepseek-ai/dsh-attachment' import { assertNever } from './never.ts' /** Execution-world path that model tools can use to read one normalized attachment. */ @@ -39,6 +40,31 @@ export function resolveImageAttachmentAccess( return readonlyPath === undefined ? undefined : { readonlyPath } } +/** + * Promote one browser-submitted prompt into durable model content: every image + * part is admitted through the attachment store before any block exists, so a + * caller-supplied part can never cite an attachment it did not upload here. + * The shared conversion for every Host prompt endpoint accepting uploads. + * @param attachments - the deployment attachment store owning admission policy. + * @param content - ordered browser prompt parts. + * @returns content blocks in part order, image parts replaced by durable references. + * @throws AttachmentError when the image batch is refused. + */ +export async function durablePromptContent( + attachments: AttachmentStore, + content: readonly PromptContentPart[], +): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + // admitEncodedImages returns one reference per image part in order. + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) +} + function quoted(value: string): string { return JSON.stringify(value) } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 748e20f412..bb0492b0af 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -4,6 +4,7 @@ import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachmen import { ToolCallId, createUserMessage, + durablePromptContent, offloadedImageText, offloadedImagePrefixCount, offloadRequestImagesWithPolicy, @@ -360,3 +361,33 @@ describe('projectImagesForTextModel', () => { ]) }) }) + +describe('durablePromptContent', () => { + it('converts text-only prompts without touching the attachment store', async () => { + const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } + await expect(durablePromptContent(store as unknown as AttachmentStore, [ + { type: 'text', text: 'hello' }, + ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('replaces image parts with admitted references in part order', async () => { + const store = { + saveImages: (inputs: readonly { data: Uint8Array }[]) => Promise.resolve(inputs.map((input, index) => ({ + attachmentId: AttachmentId(`att-${index}`), + mediaType: 'image/png' as ImageMediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + }))), + } + await expect(durablePromptContent(store as unknown as AttachmentStore, [ + { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + { type: 'text', text: 'between' }, + { type: 'image', mediaType: 'image/png', data: 'Ag==' }, + ])).resolves.toEqual([ + { type: 'image', attachment: { attachmentId: 'att-0', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'between' }, + { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ]) + }) +}) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index e807931e2c..9277bc689f 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 76df70351d711d580d3ab1a89d0f929b85eab172 -README.zh.md: c4deb001c47435d29a5ca18cc0f8c0d26e48941e +README.md: f13de661015f3aa59b776273376f07653f6bbba5 +README.zh.md: 42923504ed11b8eff7c0bdcd9d0c9505d741f42e diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 76df70351d..f13de66101 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -48,7 +48,7 @@ One-shot children run once and settle with a single result, plus an optional str ### Following up, interrupting, and discovering -Continuable children answer follow-up messages as their next turns, and the parent can interrupt a running turn or list its children at any time. Discovery covers both shapes: the service lists direct children and the full descendant tree — mode, activity, and lineage — reading live session state and optional persistence, without loading any child. +Continuable children answer follow-up messages as their next turns, and the parent can interrupt a running turn or list its children at any time. A browser continuation prompt may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Discovery covers both shapes: the service lists direct children and the full descendant tree — mode, activity, and lineage — reading live session state and optional persistence, without loading any child. ### Failure and recovery diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c4deb001c4..42923504ed 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -48,7 +48,7 @@ kind: "package-reference" ### 后续消息、中断与发现 -可继续子 agent 把后续消息作为下一个轮次回答,父级随时可以中断运行中的轮次或列举自己的子级。发现覆盖两种形态:服务列举直接子级与完整后代树——模式、活动状态与血缘——直接读取在线会话状态与可选持久化,不加载任何子 agent。 +可继续子 agent 把后续消息作为下一个轮次回答,父级随时可以中断运行中的轮次或列举自己的子级。浏览器发出的继续执行 prompt 可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。发现覆盖两种形态:服务列举直接子级与完整后代树——模式、活动状态与血缘——直接读取在线会话状态与可选持久化,不加载任何子 agent。 ### 失败与恢复 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index c89f11eee7..49f28fb095 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -54,6 +54,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -104,6 +105,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 6b50947060..f3b90a8075 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -30,7 +30,7 @@ import type { AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' -import { ReasoningEffortId, boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId, boundContextSummary, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -523,6 +523,11 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } + // Guarded call: text-only delivery must not gain an await hop inside + // the per-child lock, where it would reorder against drain admission. + if (contentHasImage(content)) { + await this.assertImageCapable(activation.handle.agent, options.signal) + } return this.submitAdmitted(activation, content, options.source, parent, options.signal) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that @@ -1021,6 +1026,9 @@ export class SubagentContinuationManager { signal: AbortSignal, ): Promise { try { + if (contentHasImage(content)) { + await this.assertImageCapable(activation.handle.agent, signal) + } return this.submitAdmitted(activation, content, source, parent, signal) } catch (error: unknown) { /* v8 ignore next -- rollback disposal failures must not mask the @@ -1030,6 +1038,37 @@ export class SubagentContinuationManager { } } + /** + * Refuse image content addressed to a child whose model accepts text only. + * Callers guard with `contentHasImage`, so text-only delivery never awaits. + * The check runs inside the per-child delivery lock, before the message + * exists, so a rejection leaves no partial user message. When the child's + * route is not fixed by its options (a request-waterfall listener owns it) + * or no LLM registry is composed, delivery proceeds and the LLM layer's + * text-only projection replaces each image with its stable placeholder. + * @param agent - the live or freshly materialized child agent. + * @param signal - caller cancellation bounding the model-info read. + * @throws {SubagentError} `MODEL_DOES_NOT_SUPPORT_IMAGES` when the child's resolved model declines image input. + */ + private async assertImageCapable( + agent: Agent, + signal: AbortSignal, + ): Promise { + const { provider, model } = agent.options + if (provider === undefined || model === undefined) return + const llm = this.ctx.get('llm') + /* v8 ignore next -- a deployment without the LLM registry serves no model + * to refuse against; delivery then defers to the text-only projection. */ + if (llm === undefined) return + const info = await llm.resolveModelInfo(provider, model, signal) + if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { + throw new SubagentError( + `Model "${model}" does not support image input.`, + 'MODEL_DOES_NOT_SUPPORT_IMAGES', + ) + } + } + /** * Create or resume the child Agent through the private activation-owner * scope, install the handle in a fresh Activation, and register ownership on diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index ea02674413..e8707cc13e 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -6,9 +6,9 @@ * @module @deepseek-ai/dsh-subagent/control-types */ +import type { PromptContentPart } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { z as zCore } from 'zod' @@ -103,8 +103,12 @@ export interface SubagentPromptRequest { readonly childSessionId: SessionId /** Required discriminator retained from the browser control address. */ readonly mode: 'continuable' - /** Content delivered as the child's user message. */ - readonly content: ContentBlock[] + /** + * Browser prompt parts delivered as the child's user message. The Host + * admits and persists image parts before delivery, so the wire never + * carries a durable attachment reference the caller could fabricate. + */ + readonly content: readonly PromptContentPart[] /** Optional browser zone sampled for this exact human prompt. */ readonly clientTimeZone?: string } @@ -127,6 +131,8 @@ export interface SubagentInterruptReceipt { */ export interface SubagentControlErrorDetailsMap { 'bad-request': { readonly issues: zCore.core.$ZodIssue[] } + /** Image admission or model image-capability refusal; `reason` carries the stable admission code. */ + 'attachment-error': { readonly reason: string } cancelled: Record 'invalid-time-zone': { readonly value: string } 'subagent-parent-unavailable': { readonly parentSessionId: SessionId } diff --git a/packages/subagent/subagent/src/control.ts b/packages/subagent/subagent/src/control.ts index 661a43153a..0c6bd9d7a4 100644 --- a/packages/subagent/subagent/src/control.ts +++ b/packages/subagent/subagent/src/control.ts @@ -7,6 +7,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { SessionId } from '@deepseek-ai/dsh-session' import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' @@ -148,8 +149,13 @@ export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: if (isCancellation(error, signal)) { return rejectControl('cancelled', 'subagent prompt was cancelled', {}) } + if (error instanceof AttachmentError) { + return rejectControl('attachment-error', error.message, { reason: error.code }) + } if (error instanceof SubagentError) { switch (error.code) { + case 'MODEL_DOES_NOT_SUPPORT_IMAGES': + return rejectControl('attachment-error', error.message, { reason: error.code }) case 'NOT_RESUMABLE': return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId }) case 'UNAUTHORIZED': diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 444f4ee857..f1a8689131 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -34,6 +34,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' +import { durablePromptContent } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -418,13 +419,15 @@ export class SubagentRuntime extends TypertRemoteService { * validated browser zone on the accepted message. Success identifies the * message the child's FIFO inbox accepted; later execution is independent of * this call. + * Image parts are admitted and persisted through the attachment store + * before delivery, and the child's model must accept image input. * @param request - durable address, minted identity, content, and optional browser zone. * @param signal - carrier cancellation, owning the call until inbox acceptance. * @returns the accepted message's inbox identity. * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, - * `subagent-parent-unavailable`, `subagent-not-resumable`, - * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or - * `internal`. + * `attachment-error`, `subagent-parent-unavailable`, + * `subagent-not-resumable`, `subagent-unauthorized`, + * `subagent-delivery-unavailable`, `cancelled`, or `internal`. */ @Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise { @@ -453,8 +456,10 @@ export class SubagentRuntime extends TypertRemoteService { rpcId: request.requestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), } - const content: ContentBlock[] = [...request.content] try { + // Admission precedes delivery: image parts become durable references + // here, so the child inbox only ever accepts Host-persisted attachments. + const content: ContentBlock[] = await durablePromptContent(this.ctx.attachments, request.content) return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } } catch (error: unknown) { return rejectPrompt(error, childSessionId, signal) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9508065197..505da10ff2 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -523,6 +523,74 @@ describe('SubagentRuntime.startContinuable', () => { }) }) +describe('continuable image follow-ups', () => { + const imageBlock = { + type: 'image' as const, + attachment: { + attachmentId: 'att-1' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, + }, + } + + it('refuses an image follow-up when the child model declines image input, leaving no partial message', async () => { + const { ctx, parent } = await setup([textResponse('child work')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo') + .mockResolvedValue({ inputModalities: ['text'] } as never) + + await expect(ctx.subagents.followup(parent, started.childId, [ + { type: 'text' as const, text: 'see this' }, + imageBlock, + ], { source: { kind: 'user' }, signal: testSignal })) + .rejects.toMatchObject({ code: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }) + + expect(resolve).toHaveBeenCalledWith('mock', 'mock', testSignal) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'see this')).toBe(false) + await drainManager(ctx) + }) + + it('delivers an image follow-up when the child model accepts image input', async () => { + const { ctx, parent } = await setup([textResponse('child work'), textResponse('image reply')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + vi.spyOn(ctx.llm, 'resolveModelInfo') + .mockResolvedValue({ inputModalities: ['text', 'image'] } as never) + + await ctx.subagents.followup(parent, started.childId, [ + { type: 'text' as const, text: 'compare' }, + imageBlock, + ], { source: { kind: 'user' }, signal: testSignal }) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const delivered = loaded.events.find(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'image')) + expect(delivered?.type === 'user/message' && delivered.data.content).toEqual([ + { type: 'text', text: 'compare' }, + imageBlock, + ]) + await drainManager(ctx) + }) + + it('defers to the text-only projection when the descriptor declares no model route', async () => { + const { ctx } = await setup([]) + const routeless = ctx.agentLoop.create(SessionId('routeless-image'), {}) + const started = await ctx.subagents.startContinuable(startSpec(routeless)) + await waitNoActivation(ctx, started.childId) + const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo') + + // Acceptance is the success boundary: with no declared route there is no + // model to refuse against, so the image message enters the child inbox. + await ctx.subagents.followup(routeless, started.childId, [imageBlock], { + source: { kind: 'user' }, signal: testSignal, + }) + + expect(resolve).not.toHaveBeenCalled() + await drainManager(ctx) + }) +}) + describe('SubagentRuntime.followup residency routing', () => { it('fails a cold follow-up when Session query is unavailable', async () => { const { ctx, parent } = await setupWith(new MockAdapter([]), { diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index dd04d549b1..99c534dd49 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime, { @@ -19,6 +20,8 @@ const OTHER = SessionId('other') const BROKEN = SessionId('broken') const REQUEST_ID = 'req-1' as SubagentPromptRequestId const signal = new AbortController().signal +/** Durable-reference base for the fake store; per-test ids and media types override. */ +const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width: 1, height: 1 } /** The runtime plus a programmable live-Agent registry, omitted to compose none. */ async function bench(live?: Record) { @@ -158,14 +161,70 @@ describe('subagent prompt Remote', () => { expect(followup).not.toHaveBeenCalled() }) - it('forwards non-text content blocks without narrowing them', async () => { - const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + it('admits ordered image parts into durable references before delivery', async () => { + const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) => + inputs.map((input, index) => ({ ...IMAGE_REF, attachmentId: `att-${index}`, mediaType: input.mediaType }))) + ctx.provide('attachments', { saveImages } as never) const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-content' as MessageId) - const content = [{ type: 'reasoning' as const, text: 'retain this block' }] + const content = [ + { type: 'text' as const, text: 'before' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=' }, + { type: 'text' as const, text: 'after' }, + ] await expect(subagents.prompt({ ...promptRequest(), content }, signal)) .resolves.toEqual({ messageId: 'm-content' }) - expect(followup.mock.calls[0]?.[2]).toEqual(content) + expect(followup.mock.calls[0]?.[2]).toEqual([ + { type: 'text', text: 'before' }, + { type: 'image', attachment: { ...IMAGE_REF, attachmentId: 'att-0', mediaType: 'image/png' } }, + { type: 'text', text: 'after' }, + ]) + }) + + it('maps a refused image batch to attachment-error and delivers nothing', async () => { + const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) + ctx.provide('attachments', { + saveImages: async () => { + throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + }, + } as never) + const followup = vi.spyOn(subagents, 'followup') + + await expect(subagents.prompt({ + ...promptRequest(), + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=' }], + }, signal)).rejects.toMatchObject({ + failure: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, + }) + expect(followup).not.toHaveBeenCalled() + }) + + it('maps non-canonical base64 to attachment-error without touching the store', async () => { + const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const saveImages = vi.fn() + ctx.provide('attachments', { saveImages } as never) + const followup = vi.spyOn(subagents, 'followup') + + await expect(subagents.prompt({ + ...promptRequest(), + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'not base64!' }], + }, signal)).rejects.toMatchObject({ + failure: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } }, + }) + expect(saveImages).not.toHaveBeenCalled() + expect(followup).not.toHaveBeenCalled() + }) + + it('maps a text-only child model refusal to attachment-error', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + vi.spyOn(subagents, 'followup').mockRejectedValue( + new SubagentError('Model "text-only" does not support image input.', 'MODEL_DOES_NOT_SUPPORT_IMAGES'), + ) + + await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({ + failure: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) }) it('delivers the content under the caller-minted identity and canonical browser zone', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f271832de3..432036f26a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8239,6 +8239,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md new file mode 100644 index 0000000000..332f611048 --- /dev/null +++ b/snapshots/web/queued-image/delivered.expected.md @@ -0,0 +1,82 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" + - button "Jump to turn 3" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- text: Stopped +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- button "queued.png, click to view original": + - img "queued.png" +- text: Compare with this screenshot {{clock}} +- button "Copy": + - img +- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": + - img + - img + - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. +- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Continue with the queued comparison {{clock}} +- button "Copy": + - img +- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": + - img + - img + - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. +- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 3 turns · 3 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md new file mode 100644 index 0000000000..462b6d03d1 --- /dev/null +++ b/snapshots/web/queued-image/queued.expected.md @@ -0,0 +1,42 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- status: Deep diving... +- list: + - listitem: + - img "Queued message image" + - text: Compare with this screenshot + - button "Edit queued message" [disabled]: + - img + - button "Remove queued message": + - img + - button "Steer queued message": + - img +- textbox "Cmd/Ctrl+Enter steers all queued messages" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/snapshots/web/queued-image/snapshot.yml b/snapshots/web/queued-image/snapshot.yml new file mode 100644 index 0000000000..69c492d2b2 --- /dev/null +++ b/snapshots/web/queued-image/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: queued-image +profile: web +composition: web-default +recording: authored +header: + class: web-default +session: + source: ../live-interactions/session.jsonl diff --git a/tsconfig.host.json b/tsconfig.host.json index b91be8e6ca..9b1f3cde4e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -60,6 +60,7 @@ "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", + "apps/web/tests/queue-image.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", From 2951512e182b84a67a3c0f271c99ab89a57b11a2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 15:59:03 +0800 Subject: [PATCH 002/429] docs: refresh module graph for subagent attachments --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5d08c21d3e..12ac07a710 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: 28885c4bde3215075f213135ad64db8feadb4a9a -module-graph.zh.md: 68d22f46c645dd62426f893bec4a0b5399d5ef6e +module-graph.md: c04dcb31589389e61f14d2fd37af56a61e600c03 +module-graph.zh.md: 2dad35fdb08790ad6e613e06bf5f1e64d1b1611f diff --git a/docs/module-graph.md b/docs/module-graph.md index 28885c4bde..c04dcb3158 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1059,6 +1059,7 @@ flowchart TD pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_attachment pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1864,7 +1865,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 68d22f46c6..2dad35fdb0 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1061,6 +1061,7 @@ flowchart TD pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_attachment pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1866,7 +1867,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | From ba810b35394ebef91d1d48db2d3295d11455b1bd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 16:31:04 +0800 Subject: [PATCH 003/429] fix: harden subagent image follow-up admission --- ...27-steer-followup-image-delivery.i18n.yaml | 4 +- ...026-08-27-steer-followup-image-delivery.md | 2 +- ...-08-27-steer-followup-image-delivery.zh.md | 2 +- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 4 +- ...026-07-27-web-subagent-conversations.zh.md | 4 +- apps/web/tests/queue-image.e2e.ts | 7 + .../src/client/queue/QueueDock.tsx | 243 +++++++++--------- packages/core/agent-loop/src/agent.ts | 6 +- .../subagent/subagent/src/continuation.ts | 22 +- packages/subagent/subagent/src/index.ts | 9 +- .../subagent/tests/continuation.spec.ts | 58 ++++- .../subagent/subagent/tests/control.spec.ts | 13 + 13 files changed, 238 insertions(+), 140 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml index 8f4d10d9e8..318e44996e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.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-27-steer-followup-image-delivery.md -2026-08-27-steer-followup-image-delivery.md: fb93612254381b589c8adfe44e46c51dfe232c3e -2026-08-27-steer-followup-image-delivery.zh.md: 35284aba89a7df5da5d472f078d2c2d3ea2e307d +2026-08-27-steer-followup-image-delivery.md: 71f1d470fa6435758a22baaf9630f5c241aabc47 +2026-08-27-steer-followup-image-delivery.zh.md: 72c47d5e3366b181b1ffbdb112cac06292616f2f diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md index fb93612254..71f1d470fa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md @@ -18,7 +18,7 @@ Third, the browser queue projection reduced a queued image to the text `[image]` **Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule. -**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]`, whose single home moved from `dsh-api-session-controller` to `dsh-attachment`; the shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. +**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]` (updating the wire contract in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)), whose single home moved from `dsh-api-session-controller` to `dsh-attachment`; the shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. **Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072). diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md index 35284aba89..72c47d5e33 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md @@ -18,7 +18,7 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186), **轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。 -**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`,该类型的唯一定义处从 `dsh-api-session-controller` 移到 `dsh-attachment`;共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 +**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`(同步更新 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 的 wire 契约),该类型的唯一定义处从 `dsh-api-session-controller` 移到 `dsh-attachment`;共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 **队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 684ebea047..9b8b530bdc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95 -2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2 +2026-07-27-web-subagent-conversations.md: 8998d6c5b828efbe3a817f50b272ee17ef5afb46 +2026-07-27-web-subagent-conversations.zh.md: f890bca9eb7a0d78813109fcee771b0e2518dc19 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index c897d345d9..8998d6c5b8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -55,9 +55,9 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`. - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent. -- `subagent.prompt` accepts only a `mode: 'continuable'` address and `ContentBlock[]`. It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`. +- `subagent.prompt` accepts only a `mode: 'continuable'` address and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`. -The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative. +The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`attachment-error`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative. Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0fdfd19b6d..f890bca9eb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -55,9 +55,9 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。 -- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与 `ContentBlock[]`。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。 +- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../bug-fix/2026-08-27-steer-followup-image-delivery.zh.md))。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。 -网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。 +网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`attachment-error`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。 diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index e5a4794d82..37f30c167b 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -119,6 +119,13 @@ describe('web e2e: queued image submission', () => { await input.fill('Continue with the queued comparison') await input.press('Enter') await settled + // The queued image message and the waking text run as two further turns; + // wait for both to end so the final snapshot never captures a mid-reply + // frame (the aborted first turn precedes them). + await expect.poll( + () => sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []), + { timeout: 15_000 }, + ).toEqual(['aborted', 'completed', 'completed']) // The delivered user message renders its image in Chat from the durable // reference, and the dock row is gone. diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index a42b8cea68..c90c699c51 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -128,133 +128,136 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que )}
} - - ))} + {queueMutable &&
+ {editing?.id === row.id + ? ( + <> + + + + + + + + ) + : ( + <> + + + + + + + + + + + )} +
} + + ) + })} diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9bac17bff0..0c99d38fbb 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -72,8 +72,10 @@ export class ReactLoopAgent implements Agent { private activityDone: Promise = Promise.resolve() /** * Identities of waking sends still awaiting a claim. Claim and discard - * notifications prune the set, and {@link cancel} clears it because a - * cancellation parks accepted-but-unclaimed input for a later waking send. + * notifications prune the set per message, while {@link cancel} and a + * pre-step rejection clear the set. Parking consumes every outstanding + * wake, including follow-ups unrelated to the rejected claim, + * because the next waking send resumes the complete parked queue anyway. * A non-empty set at driver exit therefore means a steer or follow-up lost * the race with a normally or erroneously closing turn, and the exit must * start a fresh driver to deliver it. Injected context never enters the diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index f3b90a8075..68641655ec 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -516,17 +516,25 @@ export class SubagentContinuationManager { if (activation === undefined) return this.coldResume(parent, childId, content, options) // A delivery that arrives after the disposal transaction began must not // reach a handle being torn down; wait for release, then cold-resume. + const disposal = activation.disposal /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a * delivery to observe the transaction inside the same critical section that opened it, * which no test can schedule deterministically. The behavior is covered end-to-end by * "cold-resumes a delivery that lost the race with final disposal". */ - if (activation.disposal !== undefined) { - return activation.disposal.then(() => undefined, () => undefined) + if (disposal !== undefined) { + return disposal.then(() => undefined, () => undefined) } - // Guarded call: text-only delivery must not gain an await hop inside - // the per-child lock, where it would reorder against drain admission. + // Text-only delivery stays await-free, so the disposal-cutoff check + // above and the submit share one critical window. The image path + // awaits a capability read, so it re-checks the cutoff afterwards; a + // disposal that began during the read is waited out and retried like + // one observed on entry. if (contentHasImage(content)) { await this.assertImageCapable(activation.handle.agent, options.signal) + if (activation.disposal !== undefined) { + await Promise.allSettled([activation.disposal]) + return undefined + } } return this.submitAdmitted(activation, content, options.source, parent, options.signal) }) @@ -1027,7 +1035,13 @@ export class SubagentContinuationManager { ): Promise { try { if (contentHasImage(content)) { + // The capability read awaits with the activation already published, so + // the disposal cutoff is re-checked before the submit; a drain that + // began during the read turns into a clean closing rejection. await this.assertImageCapable(activation.handle.agent, signal) + if (activation.disposal !== undefined) { + throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING') + } } return this.submitAdmitted(activation, content, source, parent, signal) } catch (error: unknown) { diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index f1a8689131..aeeeca3829 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -459,7 +459,14 @@ export class SubagentRuntime extends TypertRemoteService { try { // Admission precedes delivery: image parts become durable references // here, so the child inbox only ever accepts Host-persisted attachments. - const content: ContentBlock[] = await durablePromptContent(this.ctx.attachments, request.content) + let content: ContentBlock[] + if (request.content.every((part): part is { readonly type: 'text'; readonly text: string } => part.type === 'text')) { + content = request.content.map(part => ({ type: 'text', text: part.text })) + } else { + const attachments = this.ctx.get('attachments') + if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store') + content = await durablePromptContent(attachments, request.content) + } return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } } catch (error: unknown) { return rejectPrompt(error, childSessionId, signal) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 505da10ff2..6779c11e99 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -550,10 +550,17 @@ describe('continuable image follow-ups', () => { await drainManager(ctx) }) - it('delivers an image follow-up when the child model accepts image input', async () => { - const { ctx, parent } = await setup([textResponse('child work'), textResponse('image reply')]) + it('delivers an image follow-up to a resident child when its model accepts image input', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child work'), gate: releaseFirst.promise }, + { chunks: textResponse('image reply') }, + ]) + const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) - await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(adapter.requests).toHaveLength(1) + }) vi.spyOn(ctx.llm, 'resolveModelInfo') .mockResolvedValue({ inputModalities: ['text', 'image'] } as never) @@ -561,6 +568,7 @@ describe('continuable image follow-ups', () => { { type: 'text' as const, text: 'compare' }, imageBlock, ], { source: { kind: 'user' }, signal: testSignal }) + releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) @@ -573,6 +581,50 @@ describe('continuable image follow-ups', () => { await drainManager(ctx) }) + it('re-checks the disposal cutoff when a drain begins during a live image capability read', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('child work'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const capability = Promise.withResolvers<{ inputModalities: string[] }>() + const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo').mockReturnValue(capability.promise as never) + + const delivery = ctx.subagents.followup(parent, started.childId, [imageBlock], { + source: { kind: 'user' }, signal: testSignal, + }) + delivery.catch(() => undefined) + await vi.waitFor(() => { expect(resolve).toHaveBeenCalled() }) + releaseFirst.resolve(undefined) + const draining = drainManager(ctx) + capability.resolve({ inputModalities: ['text', 'image'] }) + + await expect(delivery).rejects.toMatchObject({ code: 'DRAINING' }) + await draining + }) + + it('rejects a materialized image follow-up whose capability read raced a drain', async () => { + const { ctx, parent } = await setup([textResponse('child work')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const capability = Promise.withResolvers<{ inputModalities: string[] }>() + const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo').mockReturnValue(capability.promise as never) + + const delivery = ctx.subagents.followup(parent, started.childId, [imageBlock], { + source: { kind: 'user' }, signal: testSignal, + }) + delivery.catch(() => undefined) + await vi.waitFor(() => { expect(resolve).toHaveBeenCalled() }) + const draining = drainManager(ctx) + capability.resolve({ inputModalities: ['text', 'image'] }) + + await expect(delivery).rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) + await draining + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'image'))).toBe(false) + }) + it('defers to the text-only projection when the descriptor declares no model route', async () => { const { ctx } = await setup([]) const routeless = ctx.agentLoop.create(SessionId('routeless-image'), {}) diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 99c534dd49..aaa2e022c6 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -216,6 +216,19 @@ describe('subagent prompt Remote', () => { expect(followup).not.toHaveBeenCalled() }) + it('rejects an image prompt when no attachment store is composed', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup') + + await expect(subagents.prompt({ + ...promptRequest(), + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=' }], + }, signal)).rejects.toMatchObject({ + failure: { code: 'internal', message: 'subagent prompt failed' }, + }) + expect(followup).not.toHaveBeenCalled() + }) + it('maps a text-only child model refusal to attachment-error', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) vi.spyOn(subagents, 'followup').mockRejectedValue( From b67663c58323fd04173cc347b2dbc60daf47ee6d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 16:42:11 +0800 Subject: [PATCH 004/429] fix: retain prompt parts in client catalog --- ...026-08-27-steer-followup-image-delivery.i18n.yaml | 4 ++-- .../2026-08-27-steer-followup-image-delivery.md | 4 ++-- .../2026-08-27-steer-followup-image-delivery.zh.md | 4 ++-- .../src/client/contract/session.ts | 6 ++---- packages/api/session-controller/src/types.ts | 12 ++++++++++-- .../tests/client-contract.client.spec.ts | 8 +++++++- .../cordis-client-runner/src/client/api-catalog.ts | 4 ++++ .../tests/api-catalog.client.spec.ts | 8 +++++++- 8 files changed, 36 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml index 318e44996e..201b7451e6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.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-27-steer-followup-image-delivery.md -2026-08-27-steer-followup-image-delivery.md: 71f1d470fa6435758a22baaf9630f5c241aabc47 -2026-08-27-steer-followup-image-delivery.zh.md: 72c47d5e3366b181b1ffbdb112cac06292616f2f +2026-08-27-steer-followup-image-delivery.md: e5b3f516e45588a865c87a1d8188b50208fbff33 +2026-08-27-steer-followup-image-delivery.zh.md: 37e55b7148841634e306b2a9d17f7f48b099c1eb diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md index 71f1d470fa..e5b3f516e4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md @@ -18,7 +18,7 @@ Third, the browser queue projection reduced a queued image to the text `[image]` **Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule. -**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]` (updating the wire contract in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)), whose single home moved from `dsh-api-session-controller` to `dsh-attachment`; the shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. +**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]` (updating the wire contract in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)). `dsh-attachment` owns the shared upload vocabulary used by the subagent route; `dsh-api-session-controller` retains a structurally identical Client-face declaration so the generated Client Cordis catalog contains the complete prompt-part fields, with a compile-time equality test preventing drift. The shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. **Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072). @@ -34,7 +34,7 @@ Third, the browser queue projection reduced a queued image to the text `[image]` ## Testing -Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, queue thumbnails (load, failure placeholder, unmount), and the image-free preview. +Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), and the image-free preview. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md index 72c47d5e33..37e55b7148 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md @@ -18,7 +18,7 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186), **轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。 -**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`(同步更新 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 的 wire 契约),该类型的唯一定义处从 `dsh-api-session-controller` 移到 `dsh-attachment`;共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 +**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`(同步更新 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 的 wire 契约)。`dsh-attachment` 负责子代理路由使用的共享上传词汇;`dsh-api-session-controller` 保留结构相同的 Client face 声明,使生成的 Client Cordis 目录包含完整的 prompt part 字段,并用编译期等价测试防止两处定义偏离。共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 **队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。 @@ -34,7 +34,7 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186), ## Testing -agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、队列缩略图(加载、失败占位、卸载)与不含图片的预览。 +agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)与不含图片的预览。 ## Consequences diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 7284f86603..9b8ed3f7ec 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,14 +7,12 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { - AttachmentIdType, ImageAttachmentRef, PromptContentPart, -} from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { QueueAction, SessionRequestId } from '../../types.ts' +import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 08b412ce46..e9e416777c 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, PromptContentPart, + AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -67,7 +67,15 @@ export interface SessionProjectionBaseline { export type SessionProjectionValues = Partial & Readonly> -export type { PromptContentPart } from '@deepseek-ai/dsh-attachment' +/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +export type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } /** Complete model selection for one Session. */ export interface ModelSelection { 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 789fe04d6d..19814e8f93 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types' import { MutableSessionEventSource, type SessionLiveEventEntry, } from '../src/client/contract/events.ts' import { transportResult } from '../src/client/contract/result.ts' +import type { PromptContentPart as SessionPromptContentPart } from '../src/types.ts' function entry(seq: number): SessionLiveEventEntry { return { @@ -17,6 +19,10 @@ function entry(seq: number): SessionLiveEventEntry { } describe('Client Session contracts', () => { + it('keeps its catalog-visible prompt parts identical to attachment intake', () => { + expectTypeOf().toEqualTypeOf() + }) + it('publishes exact replace, prepend, and append event-window changes', () => { const feed = new MutableSessionEventSource() const listener = vi.fn() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 7c9c11b781..633c553557 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -622,6 +622,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionsFace', declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot;\n}', }, + { + name: 'PromptContentPart', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + }, { name: 'PromptError', declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: ClientFailure;\n}', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 019d34ac90..4d9239da45 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -30,9 +30,15 @@ describe('Client Cordis inspect catalog', () => { it('includes the current referenced type closure for the Sessions service', () => { const result = queryServiceApi('sessions') as { - referencedTypes: readonly { name: string }[] + referencedTypes: readonly { name: string; declaration: string }[] } expect(result.referencedTypes.length).toBeGreaterThan(0) + expect(result.referencedTypes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'PromptContentPart', + declaration: expect.stringContaining("readonly type: 'image'"), + }), + ])) expect(result.referencedTypes.map(type => type.name)).not.toEqual(expect.arrayContaining([ 'ConversationSnapshot', 'PendingInteraction', From abe185205bbcce29e54e0cb5ade2f05217ee6304 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 16:49:15 +0800 Subject: [PATCH 005/429] fix: scope prompt catalog type to client --- .../src/client/contract/session.ts | 14 ++++++++++++-- packages/api/session-controller/src/types.ts | 12 ++---------- .../tests/client-contract.client.spec.ts | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 9b8ed3f7ec..7007ffc070 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,15 +7,25 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' +import type { QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' +/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +export type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } + /** * Why a local submission echo left the snapshot: `observed` when its durable * `user/message` event or host queue occurrence arrived (with the admitted diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index e9e416777c..08b412ce46 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, + AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, PromptContentPart, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -67,15 +67,7 @@ export interface SessionProjectionBaseline { export type SessionProjectionValues = Partial & Readonly> -/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ -export type PromptContentPart = - | { readonly type: 'text'; readonly text: string } - | { - readonly type: 'image' - readonly mediaType: ImageMediaType - readonly data: string - readonly name?: string - } +export type { PromptContentPart } from '@deepseek-ai/dsh-attachment' /** Complete model selection for one Session. */ export interface ModelSelection { 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 19814e8f93..e977d8a21f 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -3,8 +3,8 @@ import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek import { MutableSessionEventSource, type SessionLiveEventEntry, } from '../src/client/contract/events.ts' +import type { PromptContentPart as SessionPromptContentPart } from '../src/client/contract/session.ts' import { transportResult } from '../src/client/contract/result.ts' -import type { PromptContentPart as SessionPromptContentPart } from '../src/types.ts' function entry(seq: number): SessionLiveEventEntry { return { From 5bb24c03f1048c98687c202a577e84d6e3fb4c9e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 16:57:12 +0800 Subject: [PATCH 006/429] test: avoid untyped catalog matcher --- .../cordis-client-runner/tests/api-catalog.client.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 4d9239da45..49411941f6 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -33,12 +33,8 @@ describe('Client Cordis inspect catalog', () => { referencedTypes: readonly { name: string; declaration: string }[] } expect(result.referencedTypes.length).toBeGreaterThan(0) - expect(result.referencedTypes).toEqual(expect.arrayContaining([ - expect.objectContaining({ - name: 'PromptContentPart', - declaration: expect.stringContaining("readonly type: 'image'"), - }), - ])) + const promptContentPart = result.referencedTypes.find(type => type.name === 'PromptContentPart') + expect(promptContentPart?.declaration).toContain("readonly type: 'image'") expect(result.referencedTypes.map(type => type.name)).not.toEqual(expect.arrayContaining([ 'ConversationSnapshot', 'PendingInteraction', From 53f5418a72c97fc5354939f668fb574984b65135 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 17:25:36 +0800 Subject: [PATCH 007/429] =?UTF-8?q?fix:=20=E7=A8=B3=E5=AE=9A=20steer=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=9B=9E=E6=98=BE=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27-steer-followup-image-delivery.i18n.yaml | 4 +- ...026-08-27-steer-followup-image-delivery.md | 10 +++- ...-08-27-steer-followup-image-delivery.zh.md | 10 +++- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/session.ts | 6 +- .../src/client/contract/snapshot.ts | 5 ++ .../session-controller/src/client/index.ts | 1 + .../src/client/sessions/session.ts | 1 + ...session-pending-submissions.client.spec.ts | 18 ++++-- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/ChatView.tsx | 4 +- .../ui-chat/src/client/chat/MessageItem.tsx | 9 +-- .../ui-chat/tests/chat-view.client.spec.tsx | 55 +++++++++++++++++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/queue/QueueDock.tsx | 40 +++++++++++--- .../ui-conversation/src/client/service.ts | 4 +- .../tests/queue-dock.client.spec.tsx | 26 +++++++++ .../service-orchestration.client.spec.ts | 24 ++++++++ .../src/client/api-catalog.ts | 8 ++- 25 files changed, 205 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml index 201b7451e6..e9541319ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.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-27-steer-followup-image-delivery.md -2026-08-27-steer-followup-image-delivery.md: e5b3f516e45588a865c87a1d8188b50208fbff33 -2026-08-27-steer-followup-image-delivery.zh.md: 37e55b7148841634e306b2a9d17f7f48b099c1eb +2026-08-27-steer-followup-image-delivery.md: 37160f19f561f060c6eb3f6b5b3360bba83b0e5c +2026-08-27-steer-followup-image-delivery.zh.md: b5c0467733e1551761bed417fd2da53d86b0b05e diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md index e5b3f516e4..37160f19f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md @@ -6,7 +6,7 @@ English | [中文](2026-08-27-steer-followup-image-delivery.zh.md) ## Problem -Images submitted while an agent is running did not reliably reach the model context (#3186), for three independent reasons. +Images submitted while an agent is running did not reliably reach the model context or retain their intended browser placement (#3186), for four independent reasons. First, a steer or follow-up spliced into a live driver latched no wake: the live driver was expected to claim it, but a turn that finished or failed between the splice and the claim exited without re-checking, stranding the accepted message until an unrelated waking send. Image admission widens this window because the Host awaits attachment normalization before `agent.steer()`/`agent.followup()` runs. @@ -14,6 +14,8 @@ Second, continuable-subagent follow-ups rejected images in the Client (`SUBAGENT Third, the browser queue projection reduced a queued image to the text `[image]` even though the durable reference was already present and readable through the session attachment authorization. +Fourth, every local submission echo rendered at the Chat flow tail while the browser serialized image bytes. A direct steer therefore appeared as an ordinary chat message during the pre-admission wait, then moved to the pending-steering position when the Host queue snapshot arrived. Busy Queue sends had the same transition into QueueDock. + ## Decision **Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule. @@ -22,6 +24,8 @@ Third, the browser queue projection reduced a queued image to the text `[image]` **Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072). +**Stable optimistic placement.** `PendingSubmission` records the surface selected when submission begins: `transcript` for an idle send, `queued` for a busy Queue send, and `steering` for a busy Steer send. Chat renders transcript and steering echoes on their respective surfaces, while QueueDock renders queued echoes with browser-owned image previews. The existing `rpcId` correlation suppresses the local echo in the same render that introduces the Host queue occurrence or durable user node. If the turn closes while images serialize and the Host places a requested steer in the next-turn queue, the later move from steering to QueueDock reflects the authoritative delivery decision. + ## Alternatives considered **Re-wake on every driver exit with pending input.** Rejected: it breaks the deliberate parking semantics of `cancel({ keepInbox: true })` and pre-step rejection, and a pre-commit `turn/start` failure would re-enter a hot loop because the failing turn never claims the message. @@ -34,8 +38,8 @@ Third, the browser queue projection reduced a queued image to the text `[image]` ## Testing -Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), and the image-free preview. +Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), the image-free preview, placement capture before serialization, local steering presentation, queued echo presentation, and `rpcId` handoff on both surfaces. ## Consequences -A steer or follow-up accepted during a turn's final microtasks is now delivered by a fresh turn instead of hanging in the inbox, while user cancellation still parks pending work — delivery after a stop remains an explicit next waking send. The subagent package now depends on `dsh-attachment` and reads `ctx.llm` optionally. Images persisted by a batch whose delivery is later refused stay as unreachable content-addressed objects under the existing retention rules. Queue thumbnails add one authorized attachment read per queued image, shared with the transcript cache. +A steer or follow-up accepted during a turn's final microtasks is delivered by a fresh turn instead of hanging in the inbox, while user cancellation still parks pending work — delivery after a stop remains an explicit next waking send. Slow image serialization leaves optimistic messages on their selected transcript, QueueDock, or pending-steering surface until the Host handoff. The subagent package depends on `dsh-attachment` and reads `ctx.llm` optionally. Images persisted by a batch whose delivery is later refused stay as unreachable content-addressed objects under the existing retention rules. Queue thumbnails add one authorized attachment read per queued image, shared with the transcript cache. diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md index 37e55b7148..b5c0467733 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -agent 运行期间提交的图片没有可靠进入模型上下文(#3186),原因有三个,彼此独立。 +agent 运行期间提交的图片没有可靠进入模型上下文,也没有保持预期的浏览器显示位置(#3186),原因有四个,彼此独立。 第一,splice 进在线 driver 的 steer 或 follow-up 不会锁存唤醒:预期由在线 driver 自行认领,但轮次在 splice 与认领之间正常结束或失败时,退出路径不再复查,已接受的消息就滞留到下一次无关的唤醒发送。图片准入放大了这个窗口,因为 Host 在执行 `agent.steer()`/`agent.followup()` 之前要先等待附件规范化完成。 @@ -14,6 +14,8 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186), 第三,浏览器队列投影把已排队的图片折叠成文本 `[image]`,尽管持久化引用已经存在,并且可以通过会话附件授权读取。 +第四,浏览器序列化图片字节期间,所有本地提交回显都位于 Chat 消息流末尾。直接 steer 会在准入前等待阶段显示为普通聊天消息,Host queue snapshot 到达后才移到 pending-steering 位置。繁忙时 Queue 发送也会发生同类跳动,最终进入 QueueDock。 + ## Decision **轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。 @@ -22,6 +24,8 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186), **队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。 +**稳定的乐观显示位置。** `PendingSubmission` 记录提交开始时选定的区域:空闲发送是 `transcript`,繁忙时 Queue 发送是 `queued`,繁忙时 Steer 发送是 `steering`。Chat 分别在 transcript 与 steering 区域渲染对应回显,QueueDock 用浏览器持有的图片预览渲染 queued 回显。现有 `rpcId` 关联会在 Host queue occurrence 或持久化 user node 出现的同一次渲染中隐藏本地回显。如果图片序列化期间轮次关闭,Host 把请求的 steer 放入 next-turn queue,消息随后从 steering 移到 QueueDock,反映实际投递决定。 + ## Alternatives considered **任何 driver 退出都在有滞留输入时重新拉起。** 拒绝:这破坏 `cancel({ keepInbox: true })` 与 pre-step 拒绝的刻意停放语义,而且 `turn/start` 提交前失败的轮次永远不会认领消息,会进入热循环。 @@ -34,8 +38,8 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186), ## Testing -agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)与不含图片的预览。 +agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)、不含图片的预览、序列化前的位置捕获、steering 本地显示、queued 回显显示,以及两个区域的 `rpcId` 交接。 ## Consequences -在轮次最后几个微任务里被接受的 steer 或 follow-up 现在由新轮次投递,而不是挂在 inbox 里;用户取消仍然停放待处理工作,停止之后的投递依旧需要一次显式的唤醒发送。subagent 包新增对 `dsh-attachment` 的依赖,并可选读取 `ctx.llm`。整批持久化后投递被拒绝的图片按现有保留规则保持为不可达的内容寻址对象。队列缩略图对每张排队图片增加一次授权附件读取,与会话记录缓存共享。 +在轮次最后几个微任务里被接受的 steer 或 follow-up 由新轮次投递,不会挂在 inbox 里;用户取消仍然停放待处理工作,停止之后的投递依旧需要一次显式的唤醒发送。图片序列化较慢时,乐观消息停留在选定的 transcript、QueueDock 或 pending-steering 区域,直到与 Host 状态交接。subagent 包依赖 `dsh-attachment`,并可选读取 `ctx.llm`。整批持久化后投递被拒绝的图片按现有保留规则保持为不可达的内容寻址对象。队列缩略图对每张排队图片增加一次授权附件读取,与会话记录缓存共享。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 4f66278d0d..e68454d854 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/session-controller/README.md -README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0 -README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4 +README.md: e4a4ed9918c0d469876da7db8d37c0e00554d5b2 +README.zh.md: a9a20cc88873f56567a411459f27e9baf5132ff1 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index dc04e595c9..e4a4ed9918 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Each echo records its expected `transcript`, `queued`, or `steering` placement; this keeps slow serialization on the surface selected when submission began. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b34c8ad018..a9a20cc888 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。每条回显记录预期的 `transcript`、`queued` 或 `steering` 位置,使较慢的序列化过程始终显示在提交开始时选定的区域。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 7007ffc070..cb0290eccc 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -14,7 +14,9 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' -import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' +import type { + PendingSubmissionImage, PendingSubmissionPlacement, SessionSnapshot, +} from './snapshot.ts' /** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ export type PromptContentPart = @@ -38,6 +40,8 @@ export type PendingSubmissionRetirement = /** Input registering one local submission echo ahead of its prompt call. */ export interface BeginSubmissionInput { + /** Expected surface selected from the submission mode and running state. */ + readonly placement: PendingSubmissionPlacement /** Prompt text exactly as the upcoming prompt will send it. */ readonly text: string /** Ordered image previews matching the upcoming prompt's image parts. */ diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 8aacc0d5d6..da10cf07be 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -30,6 +30,9 @@ export interface PendingSubmissionImage { readonly height?: number } +/** Client surface selected when a local submission begins. */ +export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' + /** * One local prompt-submission echo: inserted synchronously when a submission * begins, so the conversation can show the message before serialization, @@ -39,6 +42,8 @@ export interface PendingSubmissionImage { export interface PendingSubmission { /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ readonly requestId: SessionRequestId + /** Expected surface until the Host reports the admitted queue or durable occurrence. */ + readonly placement: PendingSubmissionPlacement /** Client wall-clock ms when the submission began. */ readonly time: number /** Prompt text exactly as it will be sent (one text block). */ diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index bca9f9d3d0..1713896b61 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -62,6 +62,7 @@ export type { OpenState, PendingSubmission, PendingSubmissionImage, + PendingSubmissionPlacement, PromptError, QueuedMessage, SessionSnapshot, diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 535f6574fa..4eaf353b50 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -189,6 +189,7 @@ export class Session implements SessionFace { const requestId = randomUUID() as SessionRequestId this.pendingSubmissions = [...this.pendingSubmissions, { requestId, + placement: input.placement, time: Date.now(), text: input.text, images: input.images, diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 261bddbb78..8c108624c2 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -69,12 +69,14 @@ describe('beginSubmission', () => { const { session } = makeSession() expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) const handle = session.beginSubmission({ + placement: 'transcript', text: '你好', images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], }) expect(session.getSnapshot().promptAttempted).toBe(true) expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ requestId: handle.requestId, + placement: 'transcript', text: '你好', images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], }]) @@ -84,6 +86,7 @@ describe('beginSubmission', () => { const { session } = makeSession() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ + placement: 'transcript', text: '放弃', images: [], onRetire: retirement => retirements.push(retirement), @@ -101,6 +104,7 @@ describe('prompt-coupled retirement', () => { api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ + placement: 'transcript', text: '失败的', images: [], onRetire: retirement => retirements.push(retirement), @@ -114,7 +118,7 @@ describe('prompt-coupled retirement', () => { it('sends the echo identity as the prompt requestId', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ text: '带 id', images: [] }) + const handle = session.beginSubmission({ placement: 'transcript', text: '带 id', images: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) }) @@ -122,7 +126,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) - session.beginSubmission({ text: '还在', images: [] }) + session.beginSubmission({ placement: 'transcript', text: '还在', images: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) }) @@ -135,6 +139,7 @@ describe('observed retirement', () => { await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ + placement: 'transcript', text: '发送', images: [{ previewUrl: 'blob:p1' }], onRetire: retirement => retirements.push(retirement), @@ -153,6 +158,7 @@ describe('observed retirement', () => { const { session } = makeSession() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ + placement: 'queued', text: '排队', images: [{ previewUrl: 'blob:p1' }], onRetire: retirement => retirements.push(retirement), @@ -168,7 +174,7 @@ describe('observed retirement', () => { it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ text: '重连', images: [] }) + const handle = session.beginSubmission({ placement: 'transcript', text: '重连', images: [] }) api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) await session.open() await settleFrames() @@ -181,6 +187,7 @@ describe('observed retirement', () => { await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ + placement: 'transcript', text: '先观察', images: [], onRetire: retirement => retirements.push(retirement), @@ -197,6 +204,7 @@ describe('observed retirement', () => { await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ + placement: 'transcript', text: '同一请求', images: [], onRetire: retirement => retirements.push(retirement), @@ -221,7 +229,7 @@ describe('observed retirement', () => { const { api, session } = makeSession() api.onHistory = () => Promise.resolve(ok(historyValue([]))) await session.open() - const handle = session.beginSubmission({ text: '帧', images: [] }) + const handle = session.beginSubmission({ placement: 'transcript', text: '帧', images: [] }) await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) @@ -237,11 +245,13 @@ describe('disposal', () => { await session.open() const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] const observed = session.beginSubmission({ + placement: 'transcript', text: '已观察', images: [], onRetire: retirement => retirements.push({ text: '已观察', retirement }), }) session.beginSubmission({ + placement: 'transcript', text: '未settle', images: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5919181d67..be67d6ce37 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff -README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77 +README.md: 5e365593b054c6826de817e3e0d83734b1c8410d +README.zh.md: 0f1569ab877acd465f41baedb9a626e42eb29983 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index cd69eb7fdf..5e365593b0 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -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, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic. +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, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3e9d1ccab7..0f1569ab87 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 ## 目录 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 7b370666f9..bd5a14cf5e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -268,7 +268,9 @@ export function ChatView({ const visibleSubmissions = useMemo(() => { if (pendingSubmissions.length === 0) return pendingSubmissions const observed = observedRpcIds(order, nodeStore, inbox) - return pendingSubmissions.filter(submission => !observed.has(submission.requestId)) + return pendingSubmissions.filter(submission => ( + submission.placement !== 'queued' && !observed.has(submission.requestId) + )) }, [pendingSubmissions, order, nodeStore, inbox]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 467e486ecb..d9dd4d8e44 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -222,10 +222,10 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: { } /** - * Render one local submission echo with the exact visual language of the - * durable user node that replaces it: draft text plus object-URL previews, - * visible from the submit click until the durable `user/message` (or its - * queue occurrence) renders. + * Render one local transcript or steering submission echo with the same + * visual language and surface marker as the Host occurrence that replaces + * it: draft text plus object-URL previews, visible from the submit click + * until the durable `user/message` or steering occurrence renders. * @param props - the session snapshot's pending submission and render seats. * @returns the echoed user bubble. */ @@ -254,6 +254,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: content={content} previewImages={previewImages} renderMessageImages={renderMessageImages} + pending={submission.placement === 'steering'} echo t={t} actions={text => ( 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 58b31b4d8e..c71ba1c94b 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -762,7 +762,10 @@ describe('ChatView', () => { { nodes: [assistant(1, 'working')] }, { pendingSubmissions: [ - { requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] }, + { + requestId: 'req-1' as never, placement: 'transcript', + time: 5_000, text: '即发即显', images: [], + }, ], }, ) @@ -791,18 +794,57 @@ describe('ChatView', () => { expect(view.getAllByText('即发即显')).toHaveLength(1) }) - it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => { + it('renders a local steer echo as pending steering before Host image admission completes', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + running: true, + pendingSubmissions: [{ + requestId: 'req-steer' as never, + placement: 'steering', + time: 5_500, + text: '带图纠偏', + images: [{ previewUrl: 'blob:steer-preview', name: 'steer.png' }], + }], + }, + ) + const view = render() + const local = view.getByText('带图纠偏').closest('[data-submission-echo]') + expect(local?.hasAttribute('data-pending-steering')).toBe(true) + + act(() => { + h.setSession({ + queue: [{ + id: 'steer-occurrence' as never, + messageId: 'steer-message' as never, + placement: 'steering', + rpcId: 'req-steer' as never, + content: [{ type: 'text', text: '带图纠偏' }], + preview: '带图纠偏', + text: '带图纠偏', + }], + }) + }) + expect(view.getAllByText('带图纠偏')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() + expect(view.container.querySelector('[data-pending-steering]')).not.toBeNull() + }) + + it('keeps a queued echo out of the Chat flow before and after Host admission', () => { const h = makeHarness( { nodes: [assistant(1, 'working')] }, { running: true, pendingSubmissions: [ - { requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] }, + { + requestId: 'req-q' as never, placement: 'queued', + time: 6_000, text: '排队中', images: [], + }, ], }, ) const view = render() - expect(view.getByText('排队中')).toBeTruthy() + expect(view.queryByText('排队中')).toBeNull() act(() => { h.setSession({ queue: [{ @@ -816,8 +858,8 @@ describe('ChatView', () => { }], }) }) - // The queued occurrence renders in the queue dock, not the flow; the - // flow-tail echo yields to it in the same snapshot. + // The queued occurrence and its local predecessor both belong to the + // queue dock, never the Chat flow. expect(view.queryByText('排队中')).toBeNull() }) @@ -827,6 +869,7 @@ describe('ChatView', () => { { pendingSubmissions: [{ requestId: 'req-img' as never, + placement: 'transcript', time: 7_000, text: '', images: [ diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 656c55e85f..1a2eaf3aae 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: 6e26991dcefa8eb922f3913feb3db4750258646a -README.zh.md: 99cacf47995552bc35e2414ee64283186f2076d2 +README.md: cd410bbdb6ee403cf246962d9b8f3d17d1bc30ad +README.zh.md: a8ab2a1ea10698c1d191db6e75e222d4d19b5fb4 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 6e26991dce..cd410bbdb6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,9 +38,9 @@ The package registers the optional-Session `conversation` shell, strict Session 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. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show each durable image part as a thumbnail resolved through the session image URL cache, while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing and records the surface selected from the current running state and delivery mode: idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 99cacf4799..a8ab2a1ea1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,9 +38,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把每个持久化图片部分经会话图片 URL 缓存解析为缩略图展示,编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),并按当前运行状态与投递模式记录选定区域:空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index c90c699c51..2840268034 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -64,6 +64,14 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) { const inbox = useSession(s => s.queue) const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + const pendingQueue = useMemo(() => { + const admitted = new Set(queue.flatMap(row => row.rpcId === undefined ? [] : [row.rpcId])) + return pendingSubmissions.filter(submission => ( + submission.placement === 'queued' && !admitted.has(submission.requestId) + )) + }, [pendingSubmissions, queue]) + const rowCount = queue.length + pendingQueue.length const running = useSession(s => s.running) const queueMutable = useSession(s => s.subagent === null) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) @@ -72,15 +80,15 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que const listId = useId() useEffect(() => { - if (queue.length === 0 && !collapsed) setCollapsed(true) + if (rowCount === 0 && !collapsed) setCollapsed(true) if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null) - }, [collapsed, editing, queue, queueMutable]) + }, [collapsed, editing, queue, queueMutable, rowCount]) - if (queue.length === 0) return null + if (rowCount === 0) return null const interactionActive = queueMutable && (editing !== null || busy !== null) const expanded = !collapsed || interactionActive - const listVisible = queue.length === 1 || expanded + const listVisible = rowCount === 1 || expanded const applyAction = async ( itemId: QueueItemId, @@ -111,7 +119,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que return (
- {queue.length > 1 && ( + {rowCount > 1 && (
diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2fb0c99b51..ad56938253 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -207,7 +207,8 @@ export class ConversationController extends Service implements IConversation { if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } - if (session.getSnapshot().subagent !== null) { + const snapshot = session.getSnapshot() + if (snapshot.subagent !== null) { const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode, signal) @@ -218,6 +219,7 @@ export class ConversationController extends Service implements IConversation { ? undefined : new Promise((resolve) => { finishRetirement = resolve }) const submission = session.beginSubmission({ + placement: snapshot.running ? mode === 'steer' ? 'steering' : 'queued' : 'transcript', text, images: attachments.map(attachment => ({ previewUrl: attachment.previewUrl, diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index f1308c05c5..492bc16151 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -116,6 +116,32 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) + it('renders a queued local echo in the dock and hands off by rpcId', () => { + const pending = { + ...snapshotWith([]), + pendingSubmissions: [{ + requestId: 'req-local-queue' as never, + placement: 'queued' as const, + time: 1, + text: '等待上传', + images: [{ previewUrl: 'blob:queue-preview', name: 'queue.png' }], + }], + } + const source = liveSession(pending) + const view = render() + expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull() + expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview') + + act(() => { + source.push({ + ...pending, + queue: [{ ...row('accepted', '等待上传'), rpcId: 'req-local-queue' as never }], + }) + }) + expect(view.getAllByText('等待上传')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() + }) + it('leaves pending steering to the conversation flow', () => { const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const } const snap = snapshotWith([steering]) 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 a6582d42b8..7ecbb9679e 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -185,6 +185,7 @@ describe('sendSession submission echo', () => { const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') // Synchronous: the echo is registered before any encoding starts. expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ + placement: 'transcript', text: '带图', images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], })) @@ -211,6 +212,29 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) + it('captures the busy submission surface before image serialization', async () => { + const b = await echoBench() + try { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true }) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '立即纠偏', [], 'steer')) + .resolves.toEqual({ kind: 'success' }) + expect(b.beginSubmission).toHaveBeenLastCalledWith(expect.objectContaining({ + placement: 'steering', + text: '立即纠偏', + })) + await expect(b.root.sendSession(session, '稍后处理', [], 'queue')) + .resolves.toEqual({ kind: 'success' }) + expect(b.beginSubmission).toHaveBeenLastCalledWith(expect.objectContaining({ + placement: 'queued', + text: '稍后处理', + })) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => { const b = await echoBench() try { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 633c553557..43a19880a2 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -444,7 +444,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BeginSubmissionInput', - declaration: 'export interface BeginSubmissionInput {\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', + declaration: 'export interface BeginSubmissionInput {\n readonly placement: PendingSubmissionPlacement;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', }, { name: 'BoundActions', @@ -608,12 +608,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmission', - declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}', + declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}', }, { name: 'PendingSubmissionImage', declaration: 'export interface PendingSubmissionImage {\n readonly previewUrl: string;\n readonly name?: string;\n readonly width?: number;\n readonly height?: number;\n}', }, + { + name: 'PendingSubmissionPlacement', + declaration: 'export type PendingSubmissionPlacement = \'transcript\' | \'queued\' | \'steering\';', + }, { name: 'PendingSubmissionRetirement', declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};', From bb1df10c695db1af8c3e388f5a317ab5a8cd9ab8 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 27 Aug 2026 18:22:21 +0800 Subject: [PATCH 008/429] feat(ui-chat): collapse the turn tail into one clickable meta line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-row footer (TurnUsageDisclosure + icon-row clock chrome) with a single whole-line trigger (clock · run time · turn usage · cache hit · speed · TTFT) that opens a per-Turn usage dialog. The latest turn keeps its tail always visible; older turns reveal the whole row on hover/focus. Turns without usage data keep the plain clock line with identical spacing. --- apps/web/tests/turn-tail-actions.e2e.ts | 26 ++-- .../client/chat/MessageIconActions.module.css | 28 +++- .../src/client/chat/MessageIconActions.tsx | 35 ++--- .../client/chat/TurnTailNodeView.module.css | 7 - .../src/client/chat/TurnTailNodeView.tsx | 46 +++--- .../chat/TurnUsageDisclosure.module.css | 88 ----------- .../src/client/chat/TurnUsageDisclosure.tsx | 86 ---------- .../src/client/chat/TurnUsagePanel.module.css | 99 ++++++++++++ .../src/client/chat/TurnUsagePanel.tsx | 147 ++++++++++++++++++ packages/client/ui-chat/src/client/locale.ts | 20 +++ .../chat-font-axis-styles.client.spec.ts | 63 ++++++-- .../tests/chat-snapshot-fixture.client.ts | 5 + .../turn-usage-disclosure.client.spec.tsx | 76 --------- .../tests/turn-usage-panel.client.spec.tsx | 98 ++++++++++++ .../client/MessageFeedbackActions.module.css | 6 +- .../tests/styles.client.spec.ts | 2 +- .../web/feedback-command/ack.expected.md | 2 +- snapshots/web/message-actions/ui.expected.md | 2 +- .../web/queue-actions/preserved.expected.md | 2 +- .../seeded-history/command-row.expected.md | 2 +- .../seeded-history/feedback-row.expected.md | 2 +- snapshots/web/seeded-history/ui.expected.md | 2 +- snapshots/web/workflow-run/ui.expected.md | 2 +- 23 files changed, 510 insertions(+), 336 deletions(-) delete mode 100644 packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css delete mode 100644 packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx create mode 100644 packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css create mode 100644 packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx delete mode 100644 packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx create mode 100644 packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index c207d9278b..db2840a29e 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -170,18 +170,22 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { const { settled } = await sendPrompt(120_000) await settled - const disclosure = page.getByRole('button', { name: /Turn usage/ }) - await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1) - expect(await disclosure.getAttribute('aria-expanded')).toBe('false') - expect(await page.getByText('15.8K tok · Cache hit 49.7%', { exact: true }).count()).toBe(1) + const trigger = page.getByRole('button', { name: /Cache hit 49\.7%/ }) + await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + expect(await trigger.textContent()).toMatch(/^.+ · Ran for .+ · Turn usage 15\.8K tok · Cache hit 49\.7% · \d+ tok\/s · TTFT \S+$/) + expect(await page.getByRole('dialog').count()).toBe(0) - await disclosure.click() - expect(await disclosure.getAttribute('aria-expanded')).toBe('true') - expect(await page.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1) - expect(await page.getByText('7,891 tok', { exact: true }).count()).toBe(1) - expect(await page.getByText('7,808 tok', { exact: true }).count()).toBe(1) - expect(await page.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1) - expect(await page.getByText('15,811 tok', { exact: true }).count()).toBe(1) + await trigger.click() + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + const dialog = page.getByRole('dialog', { name: 'Turn usage' }) + expect(await dialog.count()).toBe(1) + expect(await dialog.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('7,891 tok', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('7,808 tok', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('49.7%', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1) const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE) diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css index c6f6bab161..5e31ea2e2f 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css @@ -5,7 +5,7 @@ .actions { display: flex; align-items: center; - gap: 10px; + gap: 8px; height: calc(28px + var(--dsh-content-font-delta, 0px)); } @@ -18,9 +18,11 @@ white-space: nowrap; } +/* The assistant tail's clock reads the secondary tier: its row carries the + detailed-usage cluster, so the whole meta line sits one step under body. */ .timeEnd { padding-left: 12px; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); white-space: nowrap; @@ -28,7 +30,7 @@ /* Separator between the clock and the run-time label (time · Ran for 15s). */ .runTimeDot { - margin: 0 10px; + margin: 0 5px; } /* Message containers opt in with data-time-hover-root: the time label fades @@ -44,6 +46,20 @@ [data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) { opacity: 1; } + + /* Turn tails opt in with data-actions-reveal='hover' instead: earlier turns + reveal the entire row (icons and text) on hover or focus, while the latest + turn ('always') keeps its row visible. Same opacity gate as the time + labels, so layout never moves and no-hover devices keep the row visible. */ + [data-actions-reveal='hover'] .actions { + opacity: 0; + transition: opacity 80ms ease; + } + + [data-actions-reveal='hover']:hover .actions, + [data-actions-reveal='hover']:focus-within .actions { + opacity: 1; + } } .action { @@ -60,11 +76,11 @@ cursor: pointer; } -/* The 16px action glyphs follow the same px delta as the text they serve; +/* The 15px action glyphs follow the same px delta as the text they serve; the CSS edge overrides each svg's own width/height attributes. */ .action svg { - width: calc(16px + var(--dsh-content-font-delta, 0px)); - height: calc(16px + var(--dsh-content-font-delta, 0px)); + width: calc(15px + var(--dsh-content-font-delta, 0px)); + height: calc(15px + var(--dsh-content-font-delta, 0px)); } .action:hover { diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index 7d817d6ab5..8735850b30 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -6,7 +6,7 @@ import { IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts' +import { formatMessageClock, formatRunDuration } from './message-chrome.ts' import { useCalendarDay } from './use-calendar-day.ts' import css from './MessageIconActions.module.css' @@ -17,10 +17,6 @@ export interface MessageIconActionsProps { time?: number | undefined /** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */ runMs?: number | undefined - /** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */ - ttftMs?: number | undefined - /** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */ - tokensPerSecond?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ @@ -34,6 +30,12 @@ export interface MessageIconActionsProps { * built-in copy and branch controls. */ extraActions?: ReactNode + /** + * Whole-line meta chrome (the TurnUsagePanel trigger) rendered in the `end` + * clock's seat; when present the caller omits `time`/`runMs` so the trigger + * owns the entire line. + */ + usageDetails?: ReactNode /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -44,8 +46,8 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, - extraActions, t, + text, time, runMs, clock, onBranch, branchUnavailable = false, className, + extraActions, usageDetails, t, }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() @@ -77,7 +79,7 @@ export function MessageIconActions({ }, [copied, text]) // The dot is decorative and stays hidden, but its margins separate the // readings only on screen: without the flanking spaces a reader hears one - // run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts. + // run-on string ("8/26 22:08Ran for 13s") instead of two facts. const clockEl = time === undefined ? null : ( {formatMessageClock(time, t, day)} @@ -89,22 +91,6 @@ export function MessageIconActions({ {t('message.ranFor', { duration: formatRunDuration(runMs, t) })} )} - {ttftMs !== undefined && ( - <> - {' '} - · - {' '} - {t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })} - - )} - {tokensPerSecond !== undefined && ( - <> - {' '} - · - {' '} - {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })} - - )} ) return ( @@ -136,6 +122,7 @@ export function MessageIconActions({ {t('message.branchUnavailable')} )} {clock === 'end' ? clockEl : null} + {clock === 'end' ? usageDetails : null} ) } diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css index 65d9138b77..831e6e212b 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css @@ -4,13 +4,6 @@ gap: 16px; } -.footer { - display: flex; - min-width: 0; - flex-direction: column; - gap: 4px; -} - .actions { margin-left: -6px; } diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index cc715bf450..b314779168 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -2,7 +2,7 @@ import { memo } from 'react' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' import { MessageIconActions } from './MessageIconActions.tsx' -import { TurnUsageDisclosure } from './TurnUsageDisclosure.tsx' +import { TurnUsagePanel } from './TurnUsagePanel.tsx' import { assistantText } from './turn-assistant.ts' import css from './TurnTailNodeView.module.css' @@ -16,6 +16,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ const data = node.data const hasLaterChatNode = useChat(snapshot => snapshot.locations.getTurn(data.turn).at(-1) !== node.key) + const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined @@ -34,24 +35,33 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ ? null : renderSlot('conversation.chat.assistant-actions', { messageId }) return ( -
+
{tail} -
- {data.tokenUsage === undefined ? null : } - { forkAt(closing.finalNode.seq) }} - branchUnavailable={data.branchUnavailable || hasLaterChatNode} - className={css.actions} - extraActions={assistantActions} - t={t} - /> -
+ { forkAt(closing.finalNode.seq) }} + branchUnavailable={data.branchUnavailable || hasLaterChatNode} + className={css.actions} + extraActions={assistantActions} + usageDetails={data.tokenUsage === undefined ? null : ( + + )} + t={t} + />
) }) diff --git a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css deleted file mode 100644 index ba96155504..0000000000 --- a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css +++ /dev/null @@ -1,88 +0,0 @@ -.root { - min-width: 0; -} - -.root[data-open] { - padding-bottom: 4px; -} - -.root [data-disclosure-row]:focus-visible { - border-radius: 6px; - outline: 2px solid var(--dsw-alias-label-tertiary); - outline-offset: -2px; -} - -.chevron { - color: var(--dsw-alias-label-secondary); -} - -.separator { - flex: none; - width: 2px; - height: 2px; - margin: 0 8px; - border-radius: 1px; - background: var(--dsw-alias-label-caption); -} - -.summary { - min-width: 0; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); - font-variant-numeric: tabular-nums; - line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - text-overflow: ellipsis; - white-space: nowrap; -} - -.details { - display: grid; - grid-template-columns: minmax(76px, auto) minmax(0, 1fr); - gap: 6px 16px; - box-sizing: border-box; - /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ - width: calc(100% - 22px - var(--dsh-content-font-delta, 0px)); - margin: 4px 0 0 calc(22px + var(--dsh-content-font-delta, 0px)); - padding: 10px 16px 12px 12px; - border-radius: 8px; - background: var(--dsw-alias-markdown-code-block); - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; -} - -.details dt, -.details dd { - min-width: 0; - margin: 0; -} - -.details dd { - color: var(--dsw-alias-label-secondary); - font-variant-numeric: tabular-nums; - text-align: right; -} - -.details .route { - overflow-wrap: anywhere; -} - -.reasoning { - color: var(--dsw-alias-label-tertiary); - white-space: nowrap; -} - -.totalLabel, -.details .totalValue { - padding-top: 6px; - border-top: 1px solid var(--dsw-alias-separator-primary); - color: var(--dsw-alias-label-primary); -} - -@media (max-width: 480px) { - .details { - grid-template-columns: minmax(72px, auto) minmax(0, 1fr); - gap-inline: 10px; - } -} diff --git a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx deleted file mode 100644 index 8d79b44f4d..0000000000 --- a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useState } from 'react' -import { DisclosureRow, IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { TurnTokenUsage } from '../contract/chat-nodes.ts' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' -import css from './TurnUsageDisclosure.module.css' - -export interface TurnUsageDisclosureProps { - usage: TurnTokenUsage - t: ChatViewSlotProps['t'] -} - -function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string { - return t('message.turnUsage.count', { count: formatTokens(value, t) }) -} - -function formatExactCount(value: number, t: ChatViewSlotProps['t']): string { - return t('message.turnUsage.count', { count: formatExactTokens(value, t) }) -} - -/** Compact per-Turn usage summary with an opt-in bucket breakdown. */ -export function TurnUsageDisclosure({ usage, t }: TurnUsageDisclosureProps) { - const [open, setOpen] = useState(false) - const cacheHit = usage.cacheReadTokens === undefined - ? null - : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1) - const total = formatCompactCount(usage.totalTokens, t) - const summary = cacheHit === null - ? total - : t('message.turnUsage.summaryWithCache', { total, percent: cacheHit }) - const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? '' - - return ( - } - title={t('message.turnUsage.title')} - open={open} - expandable - onToggle={() => { setOpen(value => !value) }} - expandOnRowClick - keepContentWhenOpen - collapsedContent={( - <> - - {summary} - - )} - className={css.root} - chevronClassName={css.chevron} - > -
- {routes !== '' && ( - <> -
{t('message.turnUsage.model')}
-
{routes}
- - )} -
{t('message.turnUsage.input')}
-
{formatExactCount(usage.uncachedInputTokens, t)}
- {usage.cacheReadTokens !== undefined && ( - <> -
{t('message.turnUsage.cacheRead')}
-
{formatExactCount(usage.cacheReadTokens, t)}
- - )} - {usage.cacheWriteTokens !== undefined && ( - <> -
{t('message.turnUsage.cacheWrite')}
-
{formatExactCount(usage.cacheWriteTokens, t)}
- - )} -
{t('message.turnUsage.output')}
-
- {formatExactCount(usage.outputTokens, t)} - {usage.reasoningTokens !== undefined && ( - - {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })} - - )} -
-
{t('message.turnUsage.total')}
-
{formatExactCount(usage.totalTokens, t)}
-
-
- ) -} diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css new file mode 100644 index 0000000000..1c6ac61142 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -0,0 +1,99 @@ +/* Whole-line meta trigger on the finalized footer row, plus its click-open + Turn-details dialog (menu surface: r12, inverted hairline, shadow-lv3 — + ContextMeter's panel skin). */ + +.root { + position: relative; + display: inline-flex; + min-width: 0; + /* Same 12px inset the plain clock label (.timeEnd) keeps from the icons. */ + margin-left: 12px; +} + +/* Same text treatment as the row's clock label (secondary tier, one step + under the body size); hover brightens one step to signal the cluster is + clickable. min-width 0 + hidden overflow let the one-line cluster shrink + with the chat column and ellipsize instead of widening it. */ +.trigger { + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-variant-numeric: tabular-nums; + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.trigger:hover, +.trigger[aria-expanded='true'] { + color: var(--dsw-alias-label-secondary); +} + +/* Same segment separators as the plain clock row's runTimeDot, so the + clickable and plain forms of the meta line space identically. */ +.dot { + margin: 0 5px; +} + +.panel { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + z-index: 100; + box-sizing: border-box; + /* Size to the widest row so provider/model stays on one line, within a cap. */ + width: max-content; + min-width: 300px; + max-width: min(440px, calc(100vw - 24px)); + padding: 12px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); + cursor: default; +} + +.details { + display: grid; + grid-template-columns: minmax(76px, auto) minmax(0, 1fr); + gap: 6px 16px; + margin: 0; + color: var(--dsw-alias-label-tertiary); +} + +.details dt, +.details dd { + min-width: 0; + margin: 0; +} + +.details dd { + color: var(--dsw-alias-label-secondary); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.details .route { + overflow-wrap: anywhere; +} + +.reasoning { + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +.totalLabel, +.details .totalValue { + padding-top: 8px; + border-top: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-primary); + font-weight: 500; +} diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx new file mode 100644 index 0000000000..65709cebb8 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -0,0 +1,147 @@ +// Single-row Turn chrome: the finalized footer's whole meta line (clock, +// duration, turn total, throughput, TTFT, cache-hit) as one inline trigger +// whose click opens the per-Turn details dialog. + +import { Fragment, useEffect, useRef, useState } from 'react' +import type { TurnTokenUsage } from '../contract/chat-nodes.ts' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import { + formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond, +} from './message-chrome.ts' +import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' +import { useCalendarDay } from './use-calendar-day.ts' +import css from './TurnUsagePanel.module.css' + +export interface TurnUsagePanelProps { + usage: TurnTokenUsage + /** Turn-close instant (unix epoch ms) shown as the line's leading clock. */ + time: number + /** Turn elapsed run time in ms, shown as `用时 6秒`; omitted when unrecorded. */ + runMs?: number | undefined + /** Turn first-step TTFT in ms, shown as `TTFT 1.2s`; omitted when unrecorded. */ + ttftMs?: number | undefined + /** Turn decode throughput, shown as `34 tok/s`; omitted when unrecorded. */ + tokensPerSecond?: number | undefined + /** The owning view's locale seat, passed down as a plain prop. */ + t: ChatViewSlotProps['t'] +} + +function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string { + return t('message.turnUsage.count', { count: formatTokens(value, t) }) +} + +function formatExactCount(value: number, t: ChatViewSlotProps['t']): string { + return t('message.turnUsage.count', { count: formatExactTokens(value, t) }) +} + +/** + * Whole-line clickable meta cluster with a click-open Turn-details dialog. + * @param props - Turn usage buckets, close time, optional timing metrics, locale seat. + * @returns The trigger and, while open, its anchored dialog. + */ +export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t }: TurnUsagePanelProps) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + const day = useCalendarDay() + + // Outside click / Escape close, one document listener while open (ContextMeter's pattern). + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent): void => { + if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return + setOpen(false) + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + const cacheHit = usage.cacheReadTokens === undefined + ? null + : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1) + const total = formatCompactCount(usage.totalTokens, t) + const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? '' + const clock = formatMessageClock(time, t, day) + const metrics: string[] = [clock] + if (runMs !== undefined) metrics.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) })) + metrics.push(t('message.turnUsage.consumed', { total })) + if (cacheHit !== null) metrics.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit })) + if (tokensPerSecond !== undefined) { + metrics.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) })) + } + if (ttftMs !== undefined) metrics.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })) + + return ( + + + {open && ( +
+
+ {routes !== '' && ( + <> +
{t('message.turnUsage.model')}
+
{routes}
+ + )} +
{t('message.turnUsage.input')}
+
{formatExactCount(usage.uncachedInputTokens, t)}
+ {usage.cacheReadTokens !== undefined && ( + <> +
{t('message.turnUsage.cacheRead')}
+
{formatExactCount(usage.cacheReadTokens, t)}
+ + )} + {usage.cacheWriteTokens !== undefined && ( + <> +
{t('message.turnUsage.cacheWrite')}
+
{formatExactCount(usage.cacheWriteTokens, t)}
+ + )} +
{t('message.turnUsage.output')}
+
+ {formatExactCount(usage.outputTokens, t)} + {usage.reasoningTokens !== undefined && ( + + {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })} + + )} +
+ {cacheHit !== null && ( + <> +
{t('message.turnDetails.cacheHit')}
+
{`${cacheHit}%`}
+ + )} +
{t('message.turnUsage.total')}
+
{formatExactCount(usage.totalTokens, t)}
+
+
+ )} +
+ ) +} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 3c211c9d05..2caec97796 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -92,6 +92,16 @@ export const zh = { 'message.tokensPerSecond': '{tps} tok/s', 'message.turnUsage.title': '本轮用量', 'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%', + 'message.turnUsage.consumed': '本轮用量 {total}', + 'message.turnUsage.speed': '速度 {tps} tok/s', + 'message.turnUsage.cacheHitRate': '缓存命中率 {percent}%', + 'message.turnDetails.title': '本轮详情', + 'message.turnDetails.time': '时间', + 'message.turnDetails.duration': '用时', + 'message.turnDetails.ttft': '首 token', + 'message.turnDetails.speed': '速度', + 'message.turnDetails.usage': '用量', + 'message.turnDetails.cacheHit': '缓存命中率', 'message.turnUsage.model': '提供方 / 模型', 'message.turnUsage.input': '未缓存输入', 'message.turnUsage.cacheRead': '缓存读取', @@ -205,6 +215,16 @@ export const en = { 'message.tokensPerSecond': '{tps} tok/s', 'message.turnUsage.title': 'Turn usage', 'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%', + 'message.turnUsage.consumed': 'Turn usage {total}', + 'message.turnUsage.speed': '{tps} tok/s', + 'message.turnUsage.cacheHitRate': 'Cache hit {percent}%', + 'message.turnDetails.title': 'Turn details', + 'message.turnDetails.time': 'Time', + 'message.turnDetails.duration': 'Duration', + 'message.turnDetails.ttft': 'TTFT', + 'message.turnDetails.speed': 'Speed', + 'message.turnDetails.usage': 'Usage', + 'message.turnDetails.cacheHit': 'Cache hit', 'message.turnUsage.model': 'Provider / model', 'message.turnUsage.input': 'Uncached input', 'message.turnUsage.cacheRead': 'Cached input', diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index d0b8c9cb3a..b11c53e87b 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -45,14 +45,17 @@ describe('chat flow font-size axis', () => { it('the message clock and action glyphs scale with the text they serve', () => { const actions = read('MessageIconActions.module.css') - for (const selector of ['.timeStart', '.timeEnd']) { - expect(declarationsFrom(actions, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', - ])) - } + expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size, 14px)', + ])) + // The assistant tail's meta line (the whole-line usage trigger) sits one + // step under the body size; the user row's clock keeps the body size. + expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + ])) expect(declarationsFrom(actions, '.action svg')).toEqual(expect.arrayContaining([ - 'width: calc(16px + var(--dsh-content-font-delta, 0px))', - 'height: calc(16px + var(--dsh-content-font-delta, 0px))', + 'width: calc(15px + var(--dsh-content-font-delta, 0px))', + 'height: calc(15px + var(--dsh-content-font-delta, 0px))', ])) }) @@ -80,8 +83,50 @@ describe('chat flow font-size axis', () => { .toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`])) expect(declarationsFrom(read('ContextInjectionRow.module.css'), '.body')) .toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`])) - expect(declarationsFrom(read('TurnUsageDisclosure.module.css'), '.details')) - .toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`])) + }) + + it('the usage-details trigger reads the secondary tier like its clock label', () => { + const css = read('TurnUsagePanel.module.css') + expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', + ])) + }) + + it('the whole-line trigger ellipsizes instead of widening the chat column', () => { + // The one-line meta cluster stays nowrap; min-width 0 lets the flex item + // shrink with the column and the hidden overflow trims to an ellipsis. + const css = read('TurnUsagePanel.module.css') + expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([ + 'min-width: 0', + 'white-space: nowrap', + 'overflow: hidden', + 'text-overflow: ellipsis', + ])) + expect(declarationsFrom(css, '.root')).toEqual(expect.arrayContaining(['min-width: 0'])) + }) + + it('the clickable and plain meta lines keep identical inset and separator spacing', () => { + const panel = read('TurnUsagePanel.module.css') + const actions = read('MessageIconActions.module.css') + expect(declarationsFrom(panel, '.dot')).toEqual(['margin: 0 5px']) + expect(declarationsFrom(actions, '.runTimeDot')).toEqual(['margin: 0 5px']) + expect(declarationsFrom(panel, '.root')).toEqual(expect.arrayContaining(['margin-left: 12px'])) + expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining(['padding-left: 12px'])) + }) + + it('non-latest turn tails hide the whole actions row until hover or focus', () => { + // TurnTailNodeView tags its root data-actions-reveal='hover' for every + // turn but the latest; the gate lives under @media (hover: hover) so + // no-hover devices keep the row visible. 'always' has no rule at all — + // absence, not an override, keeps the latest turn's row shown. + const css = read('MessageIconActions.module.css') + expect(declarationsFrom(css, "[data-actions-reveal='hover'] .actions")) + .toEqual(expect.arrayContaining(['opacity: 0'])) + expect(css).toMatch( + /\[data-actions-reveal='hover'\]:hover \.actions,\s*\[data-actions-reveal='hover'\]:focus-within \.actions \{\s*opacity: 1/, + ) + expect(css).not.toContain("[data-actions-reveal='always']") }) it('the interrupted-turn tag stays fixed like the dense token variants', () => { diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 39920f0201..311e2545a8 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -6,6 +6,7 @@ import type { import type { ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts' import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' import { sameTurnNavigationItem, turnNavigationItem, @@ -179,6 +180,8 @@ export function chatSnapshotFixture(input: { readonly runningCalls?: readonly RunningToolCall[] readonly turnTimings?: LegacyConversationSlice['turnTimings'] readonly turnEnds?: LegacyConversationSlice['turnEnds'] + /** Per-turn usage buckets; production derives these from session events. */ + readonly turnUsages?: ReadonlyMap | undefined } = {}, previous?: ChatSnapshot): ChatSnapshot { const legacy: LegacyConversationSlice = { nodes: input.nodes ?? EMPTY, @@ -360,6 +363,7 @@ export function chatSnapshotFixture(input: { && location.turn.turn === turnNumber }) const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber) + const tokenUsage = input.turnUsages?.get(turnNumber) const tailData = { turn: turnNumber, seq: endSeq, @@ -370,6 +374,7 @@ export function chatSnapshotFixture(input: { || (preceding.data as ReturnType).finalNode.seq !== closing.finalNode.seq, ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + ...tokenUsage === undefined ? {} : { tokenUsage }, } dataStore.set('turn-tail', tailData) nodes.push({ diff --git a/packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx deleted file mode 100644 index 23984566f3..0000000000 --- a/packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' -import { TurnUsageDisclosure } from '../src/client/chat/TurnUsageDisclosure.tsx' -import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts' -import { en } from '../src/client/locale.ts' - -const t = makeTranslate(en, commonEn) - -afterEach(cleanup) - -describe('TurnUsageDisclosure', () => { - it('shows the exact compact summary and expands into provider facts', () => { - const usage: TurnTokenUsage = { - uncachedInputTokens: 5_060, - cacheReadTokens: 4_940, - cacheWriteTokens: 0, - outputTokens: 5_800, - reasoningTokens: 42, - totalTokens: 15_800, - routes: [{ provider: 'deepseek', model: 'deepseek-chat' }], - } - const view = render() - - expect(view.getByText('15.8K tok · Cache hit 49.4%')).toBeTruthy() - expect(view.queryByRole('definition')).toBeNull() - - fireEvent.click(view.getByRole('button')) - const details = view.container.querySelector('[data-turn-usage-details]') as HTMLElement - expect(details).toBeTruthy() - expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat') - expect(details.textContent).toContain('Uncached input5,060 tok') - expect(details.textContent).toContain('Cached input4,940 tok') - expect(details.textContent).toContain('Cache write0 tok') - expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)') - expect(details.textContent).toContain('Total15,800 tok') - }) - - it('omits unavailable optional facts instead of inventing values', () => { - const usage: TurnTokenUsage = { - uncachedInputTokens: 120, - outputTokens: 30, - totalTokens: 150, - } - const view = render() - - expect(view.getByText('150 tok')).toBeTruthy() - expect(view.queryByText(/Cache hit/)).toBeNull() - fireEvent.click(view.getByRole('button')) - expect(view.queryByText('Provider / model')).toBeNull() - expect(view.queryByText('Cached input')).toBeNull() - expect(view.queryByText('Cache write')).toBeNull() - expect(view.queryByText(/reasoning/)).toBeNull() - }) - - it('keeps a partial cache hit below 100 and supports keyboard toggling', () => { - const usage: TurnTokenUsage = { - uncachedInputTokens: 1, - cacheReadTokens: 999, - outputTokens: 100, - totalTokens: 1_100, - } - const view = render() - expect(view.getByText('1.1K tok · Cache hit 99.9%')).toBeTruthy() - - const disclosure = view.getByRole('button') - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(disclosure, { key: ' ' }) - expect(disclosure.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(disclosure, { key: 'Enter' }) - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - }) -}) diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx new file mode 100644 index 0000000000..d99fc0598f --- /dev/null +++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx' +import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts' +import { en } from '../src/client/locale.ts' + +const t = makeTranslate(en, commonEn) + +// Today at 22:08 local, so formatMessageClock renders the bare `HH:mm` form. +const TIME = new Date().setHours(22, 8, 0, 0) + +afterEach(cleanup) + +describe('TurnUsagePanel', () => { + it('exposes the whole meta line inline and opens the usage dialog on click', () => { + const usage: TurnTokenUsage = { + uncachedInputTokens: 5_060, + cacheReadTokens: 4_940, + cacheWriteTokens: 0, + outputTokens: 5_800, + reasoningTokens: 42, + totalTokens: 15_800, + routes: [{ provider: 'deepseek', model: 'deepseek-chat' }], + } + const view = render( + , + ) + + const trigger = view.getByRole('button') + expect(trigger.textContent).toBe('22:08 · Ran for 13s · Turn usage 15.8K tok · Cache hit 49.4% · 107 tok/s · TTFT 2s') + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + const dialog = view.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe('Turn usage') + const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement + expect(details).toBeTruthy() + expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat') + expect(details.textContent).toContain('Uncached input5,060 tok') + expect(details.textContent).toContain('Cached input4,940 tok') + expect(details.textContent).toContain('Cache write0 tok') + expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)') + expect(details.textContent).toContain('Cache hit49.4%') + expect(details.textContent).toContain('Total15,800 tok') + }) + + it('omits unavailable optional facts instead of inventing values', () => { + const usage: TurnTokenUsage = { + uncachedInputTokens: 120, + outputTokens: 30, + totalTokens: 150, + } + const view = render() + + const trigger = view.getByRole('button') + expect(trigger.textContent).toBe('22:08 · Turn usage 150 tok') + expect(view.queryByText(/Cache hit/)).toBeNull() + expect(view.queryByText(/TTFT/)).toBeNull() + expect(view.queryByText(/tok\/s/)).toBeNull() + fireEvent.click(trigger) + expect(view.queryByText('Provider / model')).toBeNull() + expect(view.queryByText('Cached input')).toBeNull() + expect(view.queryByText('Cache write')).toBeNull() + expect(view.queryByText(/reasoning/)).toBeNull() + }) + + it('keeps a partial cache hit below 100 and closes on Escape or outside pointerdown', () => { + const usage: TurnTokenUsage = { + uncachedInputTokens: 1, + cacheReadTokens: 999, + outputTokens: 100, + totalTokens: 1_100, + } + const view = render() + const trigger = view.getByRole('button') + expect(trigger.textContent).toBe('22:08 · Turn usage 1.1K tok · Cache hit 99.9%') + + fireEvent.click(trigger) + expect(view.getByRole('dialog')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('dialog')).toBeNull() + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(trigger) + // A pointerdown inside the panel keeps it open; one outside closes it. + fireEvent.pointerDown(view.getByRole('dialog')) + expect(view.queryByRole('dialog')).toBeTruthy() + fireEvent.pointerDown(document.body) + expect(view.queryByRole('dialog')).toBeNull() + }) +}) diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css index a8c16c9383..f1050b6f2b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css @@ -15,11 +15,11 @@ cursor: pointer; } -/* The 16px glyphs follow the same px delta as the text they serve; the CSS +/* The 15px glyphs follow the same px delta as the text they serve; the CSS edge overrides each svg's own width/height attributes. */ .action svg { - width: calc(16px + var(--dsh-content-font-delta, 0px)); - height: calc(16px + var(--dsh-content-font-delta, 0px)); + width: calc(15px + var(--dsh-content-font-delta, 0px)); + height: calc(15px + var(--dsh-content-font-delta, 0px)); } .action:hover { diff --git a/packages/client/ui-message-feedback/tests/styles.client.spec.ts b/packages/client/ui-message-feedback/tests/styles.client.spec.ts index 83a049ab5d..2f2af6e341 100644 --- a/packages/client/ui-message-feedback/tests/styles.client.spec.ts +++ b/packages/client/ui-message-feedback/tests/styles.client.spec.ts @@ -97,7 +97,7 @@ describe('MessageFeedbackActions theme styles', () => { // font size moves the row. expect(block('.action')).toMatch(/width:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/) expect(block('.action')).toMatch(/height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/) - expect(block('.action svg')).toMatch(/width:\s*calc\(16px \+ var\(--dsh-content-font-delta, 0px\)\)/) + expect(block('.action svg')).toMatch(/width:\s*calc\(15px \+ var\(--dsh-content-font-delta, 0px\)\)/) expect(block('.noteOpen')).toMatch(/line-height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/) }) }) diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 0c4683b73d..dcba687138 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."': - img - img diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 53f8ac5c16..fb4fa71c14 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation" [disabled]: - img -- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} - button "Read a.txt": - img - img diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 66769e0488..ccacb6dd7b 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -29,7 +29,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- text: {{clock}} Ran for {{duration}} - button "2 queued messages" [expanded] - list: - listitem: diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index c049c42fe6..5fc1034229 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -47,7 +47,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: 7/25 {{clock}} Ran for {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index a24aaf78f0..d8a4bdf483 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -47,7 +47,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: 7/25 {{clock}} Ran for {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index d03f428f5d..b5819db45d 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -29,7 +29,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: 7/25 {{clock}} Ran for {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 9e8037c6b3..027175c4d6 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -36,4 +36,4 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} From bc88e152c547d9aacd9a8753c91995a94d11ab9d Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 27 Aug 2026 18:35:09 +0800 Subject: [PATCH 009/429] test(ui-chat): restore turn-tail assertions lost in the rebase merge The rebase onto master's turn-process folding kept master's makeHarness rework, which dropped the turnUsages passthrough and the single-trigger footer assertions; re-merge both sides. --- .../ui-chat/tests/chat-view.client.spec.tsx | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) 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 85a5d1f0c9..bf74fc890c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -92,7 +92,9 @@ function makeSessionSource(init: Partial = {}) { } } -type ChatSlice = Partial +type ChatSlice = Partial & { + readonly turnUsages?: NonNullable[0]>['turnUsages'] +} type HarnessUpdate = ChatSlice & Partial & { readonly chat?: ChatSnapshot } /** Scripted Chat target source, independent from Session lifecycle state. */ @@ -205,7 +207,7 @@ function makeHarness( chatSnapshot?: ChatSnapshot, ) { const { - chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, + chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, turnUsages, ...sessionInit } = init const chatSlice: ChatSlice = { @@ -214,6 +216,7 @@ function makeHarness( ...(runningCalls === undefined ? {} : { runningCalls }), ...(turnTimings === undefined ? {} : { turnTimings }), ...(turnEnds === undefined ? {} : { turnEnds }), + ...(turnUsages === undefined ? {} : { turnUsages }), } const session = makeSessionSource({ ...sessionInit, ...sessionOverrides }) const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot) @@ -1536,7 +1539,7 @@ describe('ChatView', () => { expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒') }) - it('the settled footer appends first-step ttft and turn decode throughput', () => { + it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => { const first: AssistantMessageNode = { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }], timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 }, @@ -1551,12 +1554,42 @@ describe('ChatView', () => { nodes: [user(1, 'hi'), first, second], turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), turnEnds: new Map([[1, 20]]), + turnUsages: new Map([[1, { + uncachedInputTokens: 5_060, + cacheReadTokens: 4_940, + outputTokens: 100, + totalTokens: 10_100, + }]]), }) const view = render() - // First-step ttft (1.2s) plus 100 tokens over 5s of decode. - expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒') - expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1) - expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1) + // First-step ttft (1.2s) plus 100 tokens over 5s of decode: the whole + // meta line (clock, run time, usage metrics) is one clickable trigger. + const trigger = view.getByRole('button', { name: /本轮用量 10\.1K tok/ }) + expect(trigger.textContent).toMatch( + /^.+ · 用时 19秒 · 本轮用量 10\.1K tok · 缓存命中率 49\.4% · 速度 20 tok\/s · 首 token 1\.2秒$/, + ) + expect(view.queryByRole('dialog')).toBeNull() + fireEvent.click(trigger) + const dialog = view.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe('本轮用量') + expect(dialog.textContent).toContain('未缓存输入5,060 tok') + expect(dialog.textContent).toContain('缓存命中率49.4%') + expect(dialog.textContent).toContain('总计10,100 tok') + }) + + it('withholds the usage-details trigger when turn usage is outside the window', () => { + const settled: AssistantMessageNode = { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }], + timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 }, + usage: { outputTokens: 40 }, + } + const h = makeHarness({ + nodes: [user(1, 'hi'), settled], + turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), + turnEnds: new Map([[1, 20]]), + }) + const view = render() + expect(view.queryByText(/首 token|tok\/s/)).toBeNull() }) it('withholds ttft and throughput while the turn is still running', () => { @@ -1574,15 +1607,28 @@ describe('ChatView', () => { expect(view.queryByText(/首 token|tok\/s/)).toBeNull() }) - it('user and assistant message containers scope the hover-revealed time chrome', () => { + it('user rows scope the hover clock; turn tails gate the whole row by recency', () => { const h = makeHarness({ - nodes: [user(1, 'hi'), assistant(2, 'answer')], - turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]), - turnEnds: new Map([[1, 2]]), + nodes: [ + user(1, 'hi'), + assistant(2, 'answer'), + user(4, 'again'), + assistant(5, 'later answer', 2), + ], + turnTimings: new Map([ + [1, { startTime: 1_000, endTime: 2_000 }], + [2, { startTime: 4_000, endTime: 5_000 }], + ]), + turnEnds: new Map([[1, 3], [2, 6]]), }) const view = render() - // The user row and the settled assistant's Turn Tail each own one clock scope. + // Only the user rows keep the time-hover scope; assistant tails moved to + // the recency-gated whole-row reveal. expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2) + const tails = view.container.querySelectorAll('[data-actions-reveal]') + expect(new Map([...tails].map(tail => [ + tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'), + ]))).toEqual(new Map([['1', 'hover'], ['2', 'always']])) }) it('the run-time label is withheld when the turn start is outside the window', () => { From 2c1cc3e7781b0b83dda2418bac92d17b429dd425 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 19:37:25 +0800 Subject: [PATCH 010/429] refactor: narrow closing-turn wake tracking --- packages/core/agent-loop/src/agent.ts | 32 ++++++--------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 0c99d38fbb..ecf921ccbd 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -71,16 +71,8 @@ export class ReactLoopAgent implements Agent { private phase: Phase private activityDone: Promise = Promise.resolve() /** - * Identities of waking sends still awaiting a claim. Claim and discard - * notifications prune the set per message, while {@link cancel} and a - * pre-step rejection clear the set. Parking consumes every outstanding - * wake, including follow-ups unrelated to the rejected claim, - * because the next waking send resumes the complete parked queue anyway. - * A non-empty set at driver exit therefore means a steer or follow-up lost - * the race with a normally or erroneously closing turn, and the exit must - * start a fresh driver to deliver it. Injected context never enters the - * set, so it keeps waiting for a waking message instead of opening a turn - * by itself. + * Waking message ids awaiting claim; cancel, rejection, and driver failure + * clear only this bookkeeping so retained inbox input parks. */ private readonly pendingWakes = new Set() @@ -141,8 +133,7 @@ export class ReactLoopAgent implements Agent { // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target - // Registered before the splice so a reentrant discard inside the splice - // dispatch still prunes it; a refused splice never leaves an entry behind. + // Track before splice so a reentrant discard prunes the wake; roll back a refused insertion. if (wakeup) this.pendingWakes.add(message.id) try { this.inbox.splice(resolvedTarget, Infinity, 0, [message]) @@ -170,9 +161,7 @@ export class ReactLoopAgent implements Agent { this.inbox.clear() if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } - // Cancellation consumes outstanding wakes: kept inbox work parks until - // the next waking send resumes the queue, and a cleared inbox has nothing - // left to deliver. + // Kept inbox work parks until the next waking send. this.pendingWakes.clear() if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } @@ -246,22 +235,17 @@ export class ReactLoopAgent implements Agent { } private async kick(): Promise { - // Set only when the turn loop returns without throwing: an abort or driver - // failure parks unclaimed waking input for the next waking send, while a - // clean exit must deliver a steer or follow-up that lost the race with the - // closing turn (its send saw a live driver, so no wake was latched). - let cleanExit = false try { while (await this.turn()) {} - cleanExit = true } catch (_error) { // Reported failures and cancellation are contained at the driver boundary. + this.pendingWakes.clear() } finally { /* v8 ignore next -- kick owns a running phase until this driver boundary */ if (this.phase.kind === 'running') { const { turn, wakeRequested } = this.phase this.setPhase({ kind: 'idle', lastTurn: turn }) - if ((wakeRequested || (cleanExit && this.pendingWakes.size > 0)) && this.inbox.hasPending) { + if ((wakeRequested || this.pendingWakes.size > 0) && this.inbox.hasPending) { this.wakeDriver() } } @@ -311,9 +295,7 @@ export class ReactLoopAgent implements Agent { const step = phase.step + 1 const decision = await this.preStep(target, { turn, step }) if (decision.kind === 'reject') { - // The rejecting listener owns resumption: input staged behind the - // rejected claim parks until the next waking send, exactly like a - // cancellation, instead of being re-offered to the same policy. + // The rejecting listener owns resumption; later input stays parked. this.pendingWakes.clear() turnEnds = { kind: 'blocked' } return false From 21d2d9395d6d61c720d380f6d18069e43dfb4b9e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 20:14:18 +0800 Subject: [PATCH 011/429] refactor: align prompt admission and echo ownership --- ...27-steer-followup-image-delivery.i18n.yaml | 4 +- ...026-08-27-steer-followup-image-delivery.md | 6 +-- ...-08-27-steer-followup-image-delivery.zh.md | 6 +-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/session.ts | 6 +-- .../src/client/sessions/session.ts | 4 +- .../api/session-controller/src/commands.ts | 6 +-- ...session-pending-submissions.client.spec.ts | 41 +++++++++++++------ .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- .../attachment/attachment/src/admission.ts | 31 +++++++++++++- packages/attachment/attachment/src/index.ts | 3 +- packages/attachment/attachment/src/types.ts | 7 +++- .../attachment/tests/admission.spec.ts | 24 ++++++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/service.ts | 2 +- .../service-orchestration.client.spec.ts | 8 ++-- .../src/client/api-catalog.ts | 2 +- packages/llm/llm/src/content.ts | 28 +------------ packages/llm/llm/tests/content.spec.ts | 31 -------------- packages/subagent/subagent/src/index.ts | 4 +- 26 files changed, 127 insertions(+), 110 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml index e9541319ae..f52fe97496 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.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-27-steer-followup-image-delivery.md -2026-08-27-steer-followup-image-delivery.md: 37160f19f561f060c6eb3f6b5b3360bba83b0e5c -2026-08-27-steer-followup-image-delivery.zh.md: b5c0467733e1551761bed417fd2da53d86b0b05e +2026-08-27-steer-followup-image-delivery.md: a7b6cdc52073b5083bee4d25ed584503ad50554f +2026-08-27-steer-followup-image-delivery.zh.md: 57f5ace239051cbe349b9c6e8231fc6b57a3d3a2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md index 37160f19f5..a7b6cdc520 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md @@ -20,11 +20,11 @@ Fourth, every local submission echo rendered at the Chat flow tail while the bro **Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule. -**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]` (updating the wire contract in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)). `dsh-attachment` owns the shared upload vocabulary used by the subagent route; `dsh-api-session-controller` retains a structurally identical Client-face declaration so the generated Client Cordis catalog contains the complete prompt-part fields, with a compile-time equality test preventing drift. The shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. +**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]` (updating the wire contract in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)). `dsh-attachment` owns the shared upload vocabulary and the `admitPromptContent()` conversion used by both the Session prompt endpoint and `SubagentRuntime.prompt`; `dsh-api-session-controller` retains a structurally identical Client-face declaration so the generated Client Cordis catalog contains the complete prompt-part fields, with a compile-time equality test preventing drift. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone. **Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072). -**Stable optimistic placement.** `PendingSubmission` records the surface selected when submission begins: `transcript` for an idle send, `queued` for a busy Queue send, and `steering` for a busy Steer send. Chat renders transcript and steering echoes on their respective surfaces, while QueueDock renders queued echoes with browser-owned image previews. The existing `rpcId` correlation suppresses the local echo in the same render that introduces the Host queue occurrence or durable user node. If the turn closes while images serialize and the Host places a requested steer in the next-turn queue, the later move from steering to QueueDock reflects the authoritative delivery decision. +**Stable optimistic placement.** Session derives a `PendingSubmission` placement synchronously from its running state and the requested delivery mode: `transcript` for an idle send, `queued` for a busy Queue send, and `steering` for a busy Steer send. The captured placement remains stable while serialization is in flight. Chat renders transcript and steering echoes on their respective surfaces, while QueueDock renders queued echoes with browser-owned image previews. The existing `rpcId` correlation suppresses the local echo in the same render that introduces the Host queue occurrence or durable user node. If the turn closes while images serialize and the Host places a requested steer in the next-turn queue, the later move from steering to QueueDock reflects the authoritative delivery decision. ## Alternatives considered @@ -38,7 +38,7 @@ Fourth, every local submission echo rendered at the Chat flow tail while the bro ## Testing -Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), the image-free preview, placement capture before serialization, local steering presentation, queued echo presentation, and `rpcId` handoff on both surfaces. +Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), the image-free preview, Session-owned placement derivation and capture, local steering presentation, queued echo presentation, and `rpcId` handoff on both surfaces. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md index b5c0467733..57f5ace239 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md @@ -20,11 +20,11 @@ agent 运行期间提交的图片没有可靠进入模型上下文,也没有 **轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。 -**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`(同步更新 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 的 wire 契约)。`dsh-attachment` 负责子代理路由使用的共享上传词汇;`dsh-api-session-controller` 保留结构相同的 Client face 声明,使生成的 Client Cordis 目录包含完整的 prompt part 字段,并用编译期等价测试防止两处定义偏离。共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 +**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`(同步更新 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 的 wire 契约)。`dsh-attachment` 负责共享上传词汇,以及 Session prompt 端点与 `SubagentRuntime.prompt` 共用的 `admitPromptContent()` 转换;`dsh-api-session-controller` 保留结构相同的 Client face 声明,使生成的 Client Cordis 目录包含完整的 prompt part 字段,并用编译期等价测试防止两处定义偏离。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。 **队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。 -**稳定的乐观显示位置。** `PendingSubmission` 记录提交开始时选定的区域:空闲发送是 `transcript`,繁忙时 Queue 发送是 `queued`,繁忙时 Steer 发送是 `steering`。Chat 分别在 transcript 与 steering 区域渲染对应回显,QueueDock 用浏览器持有的图片预览渲染 queued 回显。现有 `rpcId` 关联会在 Host queue occurrence 或持久化 user node 出现的同一次渲染中隐藏本地回显。如果图片序列化期间轮次关闭,Host 把请求的 steer 放入 next-turn queue,消息随后从 steering 移到 QueueDock,反映实际投递决定。 +**稳定的乐观显示位置。** Session 根据运行状态和请求的投递模式同步推导 `PendingSubmission` 位置:空闲发送是 `transcript`,繁忙时 Queue 发送是 `queued`,繁忙时 Steer 发送是 `steering`。该位置在序列化期间保持不变。Chat 分别在 transcript 与 steering 区域渲染对应回显,QueueDock 用浏览器持有的图片预览渲染 queued 回显。现有 `rpcId` 关联会在 Host queue occurrence 或持久化 user node 出现的同一次渲染中隐藏本地回显。如果图片序列化期间轮次关闭,Host 把请求的 steer 放入 next-turn queue,消息随后从 steering 移到 QueueDock,反映实际投递决定。 ## Alternatives considered @@ -38,7 +38,7 @@ agent 运行期间提交的图片没有可靠进入模型上下文,也没有 ## Testing -agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)、不含图片的预览、序列化前的位置捕获、steering 本地显示、queued 回显显示,以及两个区域的 `rpcId` 交接。 +agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)、Session 负责的位置推导与捕获、steering 本地显示、queued 回显显示,以及两个区域的 `rpcId` 交接。 ## Consequences diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index e68454d854..3b521194ac 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/session-controller/README.md -README.md: e4a4ed9918c0d469876da7db8d37c0e00554d5b2 -README.zh.md: a9a20cc88873f56567a411459f27e9baf5132ff1 +README.md: 50427d3072f88cc29ae9f4f8a3f8bf8f31354a64 +README.zh.md: c273fb23a9896ad002cd985c496504a0e9be1265 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index e4a4ed9918..50427d3072 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Each echo records its expected `transcript`, `queued`, or `steering` placement; this keeps slow serialization on the surface selected when submission began. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index a9a20cc888..c273fb23a9 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。每条回显记录预期的 `transcript`、`queued` 或 `steering` 位置,使较慢的序列化过程始终显示在提交开始时选定的区域。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index cb0290eccc..5fec89bc44 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -15,7 +15,7 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' import type { - PendingSubmissionImage, PendingSubmissionPlacement, SessionSnapshot, + PendingSubmissionImage, SessionSnapshot, } from './snapshot.ts' /** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ @@ -40,8 +40,8 @@ export type PendingSubmissionRetirement = /** Input registering one local submission echo ahead of its prompt call. */ export interface BeginSubmissionInput { - /** Expected surface selected from the submission mode and running state. */ - readonly placement: PendingSubmissionPlacement + /** Delivery mode used with the upcoming prompt. */ + readonly mode: 'queue' | 'steer' /** Prompt text exactly as the upcoming prompt will send it. */ readonly text: string /** Ordered image previews matching the upcoming prompt's image parts. */ diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 4eaf353b50..2dbd38b230 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -189,7 +189,9 @@ export class Session implements SessionFace { const requestId = randomUUID() as SessionRequestId this.pendingSubmissions = [...this.pendingSubmissions, { requestId, - placement: input.placement, + placement: this.running + ? input.mode === 'steer' ? 'steering' : 'queued' + : 'transcript', time: Date.now(), text: input.text, images: input.images, diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index be2b8304e2..206ebc34b8 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,10 +4,10 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { - ReasoningEffortId, createUserMessage, durablePromptContent, freezeMessage, + ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -319,7 +319,7 @@ export class SessionCommandController { ) } } - const content = await durablePromptContent(this.ctx.attachments, request.content) + const content = await admitPromptContent(this.ctx.attachments, request.content) const message: UserMessage = createUserMessage({ content, source }) if (request.mode === 'steer') agent.steer(message) else agent.followup(message) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 8c108624c2..3191949dbe 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -69,7 +69,7 @@ describe('beginSubmission', () => { const { session } = makeSession() expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) const handle = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '你好', images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], }) @@ -82,11 +82,25 @@ describe('beginSubmission', () => { }]) }) + it('derives and captures the echo placement from running state and delivery mode', () => { + const { session } = makeSession() + session.beginSubmission({ mode: 'queue', text: '空闲', images: [] }) + session.handleRunning(true) + session.beginSubmission({ mode: 'queue', text: '排队', images: [] }) + session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] }) + session.handleRunning(false) + expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([ + { text: '空闲', placement: 'transcript' }, + { text: '排队', placement: 'queued' }, + { text: '纠偏', placement: 'steering' }, + ]) + }) + it('abandon retires the echo as failed exactly once', () => { const { session } = makeSession() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '放弃', images: [], onRetire: retirement => retirements.push(retirement), @@ -104,7 +118,7 @@ describe('prompt-coupled retirement', () => { api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '失败的', images: [], onRetire: retirement => retirements.push(retirement), @@ -118,7 +132,7 @@ describe('prompt-coupled retirement', () => { it('sends the echo identity as the prompt requestId', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ placement: 'transcript', text: '带 id', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) }) @@ -126,7 +140,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) - session.beginSubmission({ placement: 'transcript', text: '还在', images: [] }) + session.beginSubmission({ mode: 'queue', text: '还在', images: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) }) @@ -139,7 +153,7 @@ describe('observed retirement', () => { await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '发送', images: [{ previewUrl: 'blob:p1' }], onRetire: retirement => retirements.push(retirement), @@ -157,8 +171,9 @@ describe('observed retirement', () => { it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { const { session } = makeSession() const retirements: PendingSubmissionRetirement[] = [] + session.handleRunning(true) const handle = session.beginSubmission({ - placement: 'queued', + mode: 'queue', text: '排队', images: [{ previewUrl: 'blob:p1' }], onRetire: retirement => retirements.push(retirement), @@ -174,7 +189,7 @@ describe('observed retirement', () => { it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ placement: 'transcript', text: '重连', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] }) api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) await session.open() await settleFrames() @@ -187,7 +202,7 @@ describe('observed retirement', () => { await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '先观察', images: [], onRetire: retirement => retirements.push(retirement), @@ -204,7 +219,7 @@ describe('observed retirement', () => { await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '同一请求', images: [], onRetire: retirement => retirements.push(retirement), @@ -229,7 +244,7 @@ describe('observed retirement', () => { const { api, session } = makeSession() api.onHistory = () => Promise.resolve(ok(historyValue([]))) await session.open() - const handle = session.beginSubmission({ placement: 'transcript', text: '帧', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] }) await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) @@ -245,13 +260,13 @@ describe('disposal', () => { await session.open() const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] const observed = session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '已观察', images: [], onRetire: retirement => retirements.push({ text: '已观察', retirement }), }) session.beginSubmission({ - placement: 'transcript', + mode: 'queue', text: '未settle', images: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index e67d95604d..6cc6a74eeb 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: 01a5d6ee143ff53175dd7327cd6d419af61938a3 -README.zh.md: 1a1d297297a6815ce5338391af0182e471f99000 +README.md: 709189ac8dd9591d89c0b0082ea0ae0247c97f97 +README.zh.md: 24eee83874d2ad12025d4457f1fad74aadb73446 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 01a5d6ee14..709189ac8d 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -75,7 +75,7 @@ The service family runs one admission-and-storage flow: every entry point enforc |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | -| [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`: canonical-base64 enforcement, then `saveImages` delegation | +| [`src/admission.ts`](src/admission.ts) | Browser prompt admission: canonical-base64 enforcement, `saveImages` delegation, and durable prompt-part projection | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; implementations enforce immutable-store checks) | diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 1a1d297297..24eee83874 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -75,7 +75,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | -| [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`:规范 base64 强制,随后委托 `saveImages` | +| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 准入:强制规范 base64、委托 `saveImages` 并投影持久 prompt part | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;实现负责强制不可变存储检查) | diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts index d31bc6d831..155b3644d8 100644 --- a/packages/attachment/attachment/src/admission.ts +++ b/packages/attachment/attachment/src/admission.ts @@ -3,7 +3,13 @@ import { Buffer } from 'node:buffer' import { AttachmentError } from './error.ts' import type { AttachmentStore } from './index.ts' -import type { EncodedImageAttachment, ImageAttachmentRef, SaveImageAttachment } from './types.ts' +import type { + AdmittedPromptContentPart, + EncodedImageAttachment, + ImageAttachmentRef, + PromptContentPart, + SaveImageAttachment, +} from './types.ts' /** Decode one upload payload while rejecting non-canonical base64 forms. */ function decodeBase64(data: string): Uint8Array { @@ -39,3 +45,26 @@ export async function admitEncodedImages( ): Promise { return attachments.saveImages(images.map(saveInput)) } + +/** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access the attachment store. + * @param attachments - the deployment attachment store owning batch policy. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +export async function admitPromptContent( + attachments: AttachmentStore, + content: readonly PromptContentPart[], +): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + // admitEncodedImages returns one reference per image part in order. + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1c0f76b9e0..9dcddeae65 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -14,10 +14,11 @@ import type { export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' -export { admitEncodedImages } from './admission.ts' +export { admitEncodedImages, admitPromptContent } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, + AdmittedPromptContentPart, EncodedImageAttachment, ImageAttachmentLimits, ImageAttachmentRef, diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 64c199eaf1..7a55c6a68f 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -55,7 +55,7 @@ export interface EncodedImageAttachment { /** * Browser-submitted prompt content accepted by Host prompt endpoints; the * accepting Host promotes image parts to durable references through - * `admitEncodedImages` before any message is created, so a wire caller can + * `admitPromptContent` before any message is created, so a wire caller can * never cite an attachment it did not upload. */ export type PromptContentPart = @@ -67,6 +67,11 @@ export type PromptContentPart = readonly name?: string } +/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +export type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { data: Uint8Array diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 4c929b6d5c..ba24d8fa20 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import { admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes @@ -64,3 +64,25 @@ describe('admitEncodedImages', () => { await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data: PNG }])).rejects.toBe(refused) }) }) + +describe('admitPromptContent', () => { + it('converts text-only prompts without touching the attachment store', async () => { + const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } + await expect(admitPromptContent(store as unknown as AttachmentStore, [ + { type: 'text', text: 'hello' }, + ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('replaces image parts with admitted references in part order', async () => { + const { store } = storeOf() + await expect(admitPromptContent(store, [ + { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + { type: 'text', text: 'between' }, + { type: 'image', mediaType: 'image/png', data: 'Ag==' }, + ])).resolves.toEqual([ + { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'between' }, + { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ]) + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1a2eaf3aae..26499140f2 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: cd410bbdb6ee403cf246962d9b8f3d17d1bc30ad -README.zh.md: a8ab2a1ea10698c1d191db6e75e222d4d19b5fb4 +README.md: 629b8b4f7987fc072066e58692396354f7b6ad6a +README.zh.md: cb622f0308ddb0a978cfb665105d79aadfb3135a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index cd410bbdb6..629b8b4f79 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing and records the surface selected from the current running state and delivery mode: idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a8ab2a1ea1..cb622f0308 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),并按当前运行状态与投递模式记录选定区域:空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index ad56938253..1b7a17c629 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -219,7 +219,7 @@ export class ConversationController extends Service implements IConversation { ? undefined : new Promise((resolve) => { finishRetirement = resolve }) const submission = session.beginSubmission({ - placement: snapshot.running ? mode === 'steer' ? 'steering' : 'queued' : 'transcript', + mode, text, images: attachments.map(attachment => ({ previewUrl: attachment.previewUrl, 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 7ecbb9679e..2823d45dc0 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -185,7 +185,7 @@ describe('sendSession submission echo', () => { const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') // Synchronous: the echo is registered before any encoding starts. expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ - placement: 'transcript', + mode: 'queue', text: '带图', images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], })) @@ -212,7 +212,7 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('captures the busy submission surface before image serialization', async () => { + it('passes each delivery mode before image serialization', async () => { const b = await echoBench() try { await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true }) @@ -220,13 +220,13 @@ describe('sendSession submission echo', () => { await expect(b.root.sendSession(session, '立即纠偏', [], 'steer')) .resolves.toEqual({ kind: 'success' }) expect(b.beginSubmission).toHaveBeenLastCalledWith(expect.objectContaining({ - placement: 'steering', + mode: 'steer', text: '立即纠偏', })) await expect(b.root.sendSession(session, '稍后处理', [], 'queue')) .resolves.toEqual({ kind: 'success' }) expect(b.beginSubmission).toHaveBeenLastCalledWith(expect.objectContaining({ - placement: 'queued', + mode: 'queue', text: '稍后处理', })) } finally { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 43a19880a2..011bfd337a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -444,7 +444,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BeginSubmissionInput', - declaration: 'export interface BeginSubmissionInput {\n readonly placement: PendingSubmissionPlacement;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', + declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', }, { name: 'BoundActions', diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index faac6249af..43c392a641 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,8 +2,7 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, PromptContentPart, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { assertNever } from './never.ts' /** Execution-world path that model tools can use to read one normalized attachment. */ @@ -40,31 +39,6 @@ export function resolveImageAttachmentAccess( return readonlyPath === undefined ? undefined : { readonlyPath } } -/** - * Promote one browser-submitted prompt into durable model content: every image - * part is admitted through the attachment store before any block exists, so a - * caller-supplied part can never cite an attachment it did not upload here. - * The shared conversion for every Host prompt endpoint accepting uploads. - * @param attachments - the deployment attachment store owning admission policy. - * @param content - ordered browser prompt parts. - * @returns content blocks in part order, image parts replaced by durable references. - * @throws AttachmentError when the image batch is refused. - */ -export async function durablePromptContent( - attachments: AttachmentStore, - content: readonly PromptContentPart[], -): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) -} - function quoted(value: string): string { return JSON.stringify(value) } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index bb0492b0af..748e20f412 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -4,7 +4,6 @@ import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachmen import { ToolCallId, createUserMessage, - durablePromptContent, offloadedImageText, offloadedImagePrefixCount, offloadRequestImagesWithPolicy, @@ -361,33 +360,3 @@ describe('projectImagesForTextModel', () => { ]) }) }) - -describe('durablePromptContent', () => { - it('converts text-only prompts without touching the attachment store', async () => { - const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } - await expect(durablePromptContent(store as unknown as AttachmentStore, [ - { type: 'text', text: 'hello' }, - ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) - }) - - it('replaces image parts with admitted references in part order', async () => { - const store = { - saveImages: (inputs: readonly { data: Uint8Array }[]) => Promise.resolve(inputs.map((input, index) => ({ - attachmentId: AttachmentId(`att-${index}`), - mediaType: 'image/png' as ImageMediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - }))), - } - await expect(durablePromptContent(store as unknown as AttachmentStore, [ - { type: 'image', mediaType: 'image/png', data: 'AQ==' }, - { type: 'text', text: 'between' }, - { type: 'image', mediaType: 'image/png', data: 'Ag==' }, - ])).resolves.toEqual([ - { type: 'image', attachment: { attachmentId: 'att-0', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, - { type: 'text', text: 'between' }, - { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, - ]) - }) -}) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index aeeeca3829..83ae305084 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -30,11 +30,11 @@ */ import { Context } from '@deepseek-ai/cordis' +import { admitPromptContent } from '@deepseek-ai/dsh-attachment' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' -import { durablePromptContent } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -465,7 +465,7 @@ export class SubagentRuntime extends TypertRemoteService { } else { const attachments = this.ctx.get('attachments') if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store') - content = await durablePromptContent(attachments, request.content) + content = await admitPromptContent(attachments, request.content) } return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } } catch (error: unknown) { From 145f9369461b8a38f43b5f2c141408b537079398 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 20:29:25 +0800 Subject: [PATCH 012/429] test: stabilize queued image browser snapshot --- apps/web/tests/queue-image.e2e.ts | 1 + snapshots/web/queued-image/delivered.expected.md | 15 ++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index 37f30c167b..2f57a72c48 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -105,6 +105,7 @@ describe('web e2e: queued image submission', () => { await dockThumb.waitFor({ timeout: 15_000 }) await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/) await page.getByText(QUEUED_TEXT, { exact: true }).waitFor() + await page.getByRole('button', { name: 'Remove queued message' }).waitFor({ timeout: 15_000 }) const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE) diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 332f611048..b2b220da72 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -20,10 +20,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": @@ -40,10 +39,9 @@ - text: Compare with this screenshot {{clock}} - button "Copy": - img -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img @@ -56,10 +54,9 @@ - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Continue with the queued comparison {{clock}} - button "Copy": - img -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img From 318bf7d2c135cad70669c9baad2d53748f622e33 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 20:44:35 +0800 Subject: [PATCH 013/429] test: account for Windows chmod coverage --- packages/context/file-reference-local/src/search.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/context/file-reference-local/src/search.ts b/packages/context/file-reference-local/src/search.ts index ba5d30c2f3..ad4571cc97 100644 --- a/packages/context/file-reference-local/src/search.ts +++ b/packages/context/file-reference-local/src/search.ts @@ -295,10 +295,12 @@ async function readDirectory(absolute: string, signal: AbortSignal) { signal.throwIfAborted() return entries.sort((left, right) => compareText(left.name, right.name)) } catch (_error: unknown) { + /* v8 ignore start -- Windows chmod cannot make the unreadable-directory fixture fail readdir; POSIX behavior covers this fallback. */ signal.throwIfAborted() // An unreadable/missing subtree contributes no candidates; other readable // branches remain useful and autocomplete is advisory. return [] + /* v8 ignore stop */ } } From 56f1a3bde6f09c23790b4bc086a953aa8badd87d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 21:23:49 +0800 Subject: [PATCH 014/429] test: gate unreadable directory case to POSIX --- packages/context/file-reference-local/tests/search.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/context/file-reference-local/tests/search.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts index 7b48148bf3..3bba97f88a 100644 --- a/packages/context/file-reference-local/tests/search.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -199,7 +199,7 @@ describe('WorkspaceFileSearch', () => { }) }) - it('lets an unreadable subtree cost only its own candidates', async () => { + it.skipIf(process.platform === 'win32')('lets an unreadable subtree cost only its own candidates', async () => { const root = await workspace() const locked = join(root, 'locked') await mkdir(locked, { recursive: true }) From 266440c5a782a094087385278ec759d4cf834035 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 21:25:46 +0800 Subject: [PATCH 015/429] test: allow Windows merge-driver latency --- scripts/translation-pairing-merge.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 7293989c2e..2fcc9b0729 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -30,6 +30,7 @@ const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver const workspaceRoot = fileURLToPath(new URL('../', import.meta.url)) const tsxLoader = import.meta.resolve('tsx/esm') const fixtures: string[] = [] +const compositionTimeoutMs = process.platform === 'win32' ? 60_000 : 15_000 interface Fixture { env: NodeJS.ProcessEnv @@ -261,7 +262,7 @@ function expectMergedPair(fixture: Fixture): void { ) } -describe('translation pairing merge composition', { timeout: 15_000 }, () => { +describe('translation pairing merge composition', { timeout: compositionTimeoutMs }, () => { it('rejects a pairing-record path outside the repository', () => { const fixture = createFixture(false) From 267bdd60aa5a676cda0e05a8071a8192d886e8c6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 22:26:06 +0800 Subject: [PATCH 016/429] test: allow loaded Windows coverage timing --- .../tests/agent-instructions.spec.ts | 19 ++++++++++--------- scripts/install-lefthook.spec.ts | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index a1101a390c..9444e2e445 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -47,6 +47,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName) const testToolSignal = new AbortController().signal +const requestTimeoutMs = process.platform === 'win32' ? 5_000 : 1_000 async function tempRepo(): Promise { return mkdtemp(join(tmpdir(), 'dsh-workspace-context-')) @@ -1348,7 +1349,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const inserted = original.inbox.nextStep[0] @@ -1361,7 +1362,7 @@ describe('workspace context request injection', () => { const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') @@ -1393,7 +1394,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1407,7 +1408,7 @@ describe('workspace context request injection', () => { const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), ) @@ -1446,7 +1447,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(originalCtx, original).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1460,7 +1461,7 @@ describe('workspace context request injection', () => { const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( 'agent/pre-step', - { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) @@ -1564,7 +1565,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve(downstream), ) @@ -1621,7 +1622,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve(downstream), ) @@ -1732,7 +1733,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, () => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }), ) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index f0c76ead6f..c7a24488f8 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -25,7 +25,7 @@ const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/packa const fixtures: string[] = [] // Multi-worktree cases spawn several Git and Node subprocesses; native Windows // coverage concurrency can delay them without changing installer behavior. -const MULTI_PROCESS_TEST_TIMEOUT_MS = 30_000 +const MULTI_PROCESS_TEST_TIMEOUT_MS = process.platform === 'win32' ? 90_000 : 30_000 interface Fixture { container: string From 6f446e196b578f0ac5437a61ff12fdea08f1f80c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 22:29:06 +0800 Subject: [PATCH 017/429] ci: retrigger pull request workflows From f15078532fa8ad8540ccc9502b9372656a257d75 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 27 Aug 2026 23:14:08 +0800 Subject: [PATCH 018/429] feat(ui-chat): reshape the usage trigger as an icon-row pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-line meta trigger read as plain text and hid what was clickable. The Turn-usage trigger is now a data-icon pill (Usage {total} · Cache hit {percent}%) seated right of the branch action with the action buttons' hover chrome, so the one interactive element in the tail is visibly a button; the timing facts (clock, run time, speed, TTFT) return to plain non-clickable text behind a dot separator. The details dialog keeps the Turn-usage title and full token buckets, gains a permanent cache-hit row, and breathes with wider vertical padding. Narrow columns trim the pill label to an ellipsis instead of widening the chat column. User rows and turn tails share the recency gate: only the latest row of each kind keeps its actions visible without hover. --- .../skill-user-invoke/ui-expanded.expected.md | 2 +- .../expected/skill-user-invoke/ui.expected.md | 2 +- .../steer-all/settled-expanded.expected.md | 2 +- .../expected/steer-all/settled.expected.md | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 9 +- packages/client/ui-chat/package.json | 4 +- .../client/chat/MessageIconActions.module.css | 47 ++++---- .../src/client/chat/MessageIconActions.tsx | 60 ++++++---- .../ui-chat/src/client/chat/MessageItem.tsx | 18 ++- .../src/client/chat/TurnTailNodeView.tsx | 17 +-- .../src/client/chat/TurnUsagePanel.module.css | 75 ++++++++---- .../src/client/chat/TurnUsagePanel.tsx | 113 ++++++++++-------- packages/client/ui-chat/src/client/locale.ts | 26 +--- .../tests/chat-branch-tails.client.spec.tsx | 8 +- .../chat-font-axis-styles.client.spec.ts | 38 +++--- .../ui-chat/tests/chat-view.client.spec.tsx | 31 +++-- .../tests/turn-usage-panel.client.spec.tsx | 36 +++--- .../ui-primitives/src/useAnchoredPosition.ts | 20 ++-- pnpm-lock.yaml | 6 + snapshots/web/code-mode-round/ui.expected.md | 2 +- .../feedback-command/ack-expanded.expected.md | 2 +- .../web/feedback-command/ack.expected.md | 2 +- .../feedback-release/ack-expanded.expected.md | 2 +- .../web/feedback-release/ack.expected.md | 2 +- .../fresh-round-trip/ui-expanded.expected.md | 2 +- snapshots/web/fresh-round-trip/ui.expected.md | 2 +- .../ui-expanded.expected.md | 4 +- .../goal-multi-turn-actions/ui.expected.md | 4 +- .../reloaded-expanded.expected.md | 2 +- .../web/lifecycle-chrome/reloaded.expected.md | 2 +- .../retry-expanded.expected.md | 2 +- .../web/live-interactions/retry.expected.md | 2 +- snapshots/web/message-actions/ui.expected.md | 2 +- snapshots/web/minimal-preset/ui.expected.md | 2 +- .../plan-review/approved-expanded.expected.md | 2 +- .../web/plan-review/approved.expected.md | 2 +- .../answered-expanded.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../web/queue-actions/preserved.expected.md | 2 +- .../seeded-history/command-row.expected.md | 2 +- .../seeded-history/feedback-row.expected.md | 2 +- .../seeded-history/ui-expanded.expected.md | 2 +- snapshots/web/seeded-history/ui.expected.md | 2 +- snapshots/web/skill-tool-row/ui.expected.md | 2 +- .../web/steering/settled-expanded.expected.md | 2 +- snapshots/web/steering/settled.expected.md | 2 +- .../ui-expanded.expected.md | 4 +- .../web/subagent-conversation/ui.expected.md | 4 +- .../turn-tail-actions/completed.expected.md | 9 +- .../web/turn-tail-actions/focused.expected.md | 9 +- .../web/turn-tail-actions/settled.expected.md | 2 +- .../usage-expanded.expected.md | 18 +-- snapshots/web/web-search-round/ui.expected.md | 2 +- snapshots/web/workflow-run/ui.expected.md | 2 +- 54 files changed, 344 insertions(+), 280 deletions(-) diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 8a1e8287a7..f06169c311 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index dea9369875..3281b27094 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 39c0dc68da..c220ca0d5d 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -46,7 +46,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index f17b2e14c7..7a9146a5b5 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -34,7 +34,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index db2840a29e..99b2572d60 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -170,10 +170,13 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { const { settled } = await sendPrompt(120_000) await settled - const trigger = page.getByRole('button', { name: /Cache hit 49\.7%/ }) + const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ }) await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1) expect(await trigger.getAttribute('aria-expanded')).toBe('false') - expect(await trigger.textContent()).toMatch(/^.+ · Ran for .+ · Turn usage 15\.8K tok · Cache hit 49\.7% · \d+ tok\/s · TTFT \S+$/) + // The pill carries the icon, the turn total, and the cache-hit rate; + // timing facts stay in the plain meta line beside it. + expect(await trigger.textContent()).toBe('Usage 15.8K tok · Cache hit 49.7%') + expect(await page.getByText(/^.+ · Ran for .+ · \d+ tok\/s · TTFT \S+$/).count()).toBe(1) expect(await page.getByRole('dialog').count()).toBe(0) await trigger.click() @@ -181,10 +184,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { const dialog = page.getByRole('dialog', { name: 'Turn usage' }) expect(await dialog.count()).toBe(1) expect(await dialog.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('49.7%', { exact: true }).count()).toBe(1) expect(await dialog.getByText('7,891 tok', { exact: true }).count()).toBe(1) expect(await dialog.getByText('7,808 tok', { exact: true }).count()).toBe(1) expect(await dialog.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1) - expect(await dialog.getByText('49.7%', { exact: true }).count()).toBe(1) expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1) const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 10382a63bf..945f4cfeb5 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -107,7 +107,9 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css index 5e31ea2e2f..37b48b77dc 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css @@ -1,6 +1,6 @@ /* Shared message IconActions row (user + assistant). Parent modules own - layout offsets via the composed className. Icons stay visible when mounted; - the time label is hover-revealed inside a data-time-hover-root scope. */ + layout offsets via the composed className. Row containers gate visibility + by recency through data-actions-reveal. */ .actions { display: flex; @@ -9,10 +9,12 @@ height: calc(28px + var(--dsh-content-font-delta, 0px)); } -/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */ +/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). + Both read the secondary tier so the user row's clock matches the assistant + tail's meta line. */ .timeStart { padding-right: 12px; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); white-space: nowrap; @@ -33,24 +35,27 @@ margin: 0 5px; } -/* Message containers opt in with data-time-hover-root: the time label fades - in on message hover (or keyboard focus within). Opacity keeps the layout - stable, and devices without hover keep the label always visible. */ +/* Separator between the usage pill and the plain meta text; the row's 8px + flex gap spaces it, so the meta line drops its own 12px inset. The negative + margin rebates most of the pill's internal 8px padding so the label-to-dot + distance reads like the dot's other side, while keeping 2px clear of the + pill's hover background. */ +.actionsDot { + margin-left: -6px; + font-size: var(--dsh-content-font-size-secondary, 13px); + color: var(--dsw-alias-label-tertiary); +} + +.actionsDot + .timeEnd { + padding-left: 0; +} + +/* Row containers (user rows and turn tails) opt in with + data-actions-reveal='hover': every row but the latest of its kind reveals + the entire actions row (icons and text) on hover or focus-within, while the + latest ('always') keeps its row visible — no rule matches it. Opacity keeps + the layout stable, and devices without hover keep the row visible. */ @media (hover: hover) { - [data-time-hover-root] :is(.timeStart, .timeEnd) { - opacity: 0; - transition: opacity 80ms ease; - } - - [data-time-hover-root]:hover :is(.timeStart, .timeEnd), - [data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) { - opacity: 1; - } - - /* Turn tails opt in with data-actions-reveal='hover' instead: earlier turns - reveal the entire row (icons and text) on hover or focus, while the latest - turn ('always') keeps its row visible. Same opacity gate as the time - labels, so layout never moves and no-hover devices keep the row visible. */ [data-actions-reveal='hover'] .actions { opacity: 0; transition: opacity 80ms ease; diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index 8735850b30..b53ff20ae6 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -1,12 +1,14 @@ // Shared IconActions chrome for user and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' +import { Fragment, useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { formatMessageClock, formatRunDuration } from './message-chrome.ts' +import { + formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond, +} from './message-chrome.ts' import { useCalendarDay } from './use-calendar-day.ts' import css from './MessageIconActions.module.css' @@ -17,6 +19,10 @@ export interface MessageIconActionsProps { time?: number | undefined /** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */ runMs?: number | undefined + /** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */ + tokensPerSecond?: number | undefined + /** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */ + ttftMs?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ @@ -31,11 +37,10 @@ export interface MessageIconActionsProps { */ extraActions?: ReactNode /** - * Whole-line meta chrome (the TurnUsagePanel trigger) rendered in the `end` - * clock's seat; when present the caller omits `time`/`runMs` so the trigger - * owns the entire line. + * Icon-row Turn-usage trigger (the TurnUsagePanel pill), seated after the + * branch control at the end of the icon cluster. */ - usageDetails?: ReactNode + usageAction?: ReactNode /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -46,8 +51,8 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, clock, onBranch, branchUnavailable = false, className, - extraActions, usageDetails, t, + text, time, runMs, tokensPerSecond, ttftMs, clock, onBranch, branchUnavailable = false, className, + extraActions, usageAction, t, }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() @@ -77,20 +82,30 @@ export function MessageIconActions({ }, 1000) }) }, [copied, text]) - // The dot is decorative and stays hidden, but its margins separate the + // The dots are decorative and stay hidden, but their margins separate the // readings only on screen: without the flanking spaces a reader hears one - // run-on string ("8/26 22:08Ran for 13s") instead of two facts. - const clockEl = time === undefined ? null : ( + // run-on string ("8/26 22:08Ran for 13s") instead of separate facts. + const meta: string[] = [] + if (time !== undefined) meta.push(formatMessageClock(time, t, day)) + if (runMs !== undefined) meta.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) })) + if (tokensPerSecond !== undefined) { + meta.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) })) + } + if (ttftMs !== undefined) meta.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })) + const clockEl = meta.length === 0 ? null : ( - {formatMessageClock(time, t, day)} - {runMs !== undefined && ( - <> - {' '} - · - {' '} - {t('message.ranFor', { duration: formatRunDuration(runMs, t) })} - - )} + {meta.map((segment, index) => ( + + {index > 0 && ( + <> + {' '} + · + {' '} + + )} + {segment} + + ))} ) return ( @@ -121,8 +136,11 @@ export function MessageIconActions({ {onBranch !== undefined && branchUnavailable && ( {t('message.branchUnavailable')} )} + {usageAction} + {usageAction != null && clock === 'end' && clockEl !== null && ( + · + )} {clock === 'end' ? clockEl : null} - {clock === 'end' ? usageDetails : null}
) } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 467e486ecb..cb8084fe41 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -162,6 +162,8 @@ function UserStyleBubble({ referenceLabels?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ previewImages?: readonly MessageImageSource[] + /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown (turn tails' gate). */ + reveal?: 'always' | 'hover' t: ChatViewSlotProps['t'] }): ReactNode { const { text, images: contentImages, rest } = contentParts(content) @@ -173,7 +175,7 @@ function UserStyleBubble({ className={css.userRow} data-pending-steering={pending || undefined} data-submission-echo={echo || undefined} - data-time-hover-root + data-actions-reveal={reveal} >
{renderMessageImages({ images, align: 'end' })} @@ -271,14 +273,24 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ - node, renderMessageImages, t, + node, renderMessageImages, useChat, t, }: ChatNodeViewProps<'user' | 'steering'>) { const data = node.data + // The transcript's last user-authored row keeps its actions row shown, the + // same recency gate turn tails use; earlier rows reveal on hover. + const isLatestUserRow = useChat((snapshot) => { + for (let index = snapshot.order.length - 1; index >= 0; index -= 1) { + const candidate = snapshot.nodes.get(snapshot.order[index] ?? '') + if (candidate?.kind === 'user' || candidate?.kind === 'steering') return candidate.key === node.key + } + return true + }) return ( ( { forkAt(closing.finalNode.seq) }} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} - usageDetails={data.tokenUsage === undefined ? null : ( - + usageAction={data.tokenUsage === undefined ? null : ( + )} t={t} /> diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index 1c6ac61142..f95534d7c0 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -1,56 +1,68 @@ -/* Whole-line meta trigger on the finalized footer row, plus its click-open +/* Icon-row Turn-usage pill (data icon + turn total) plus its click-open Turn-details dialog (menu surface: r12, inverted hairline, shadow-lv3 — ContextMeter's panel skin). */ .root { - position: relative; display: inline-flex; min-width: 0; - /* Same 12px inset the plain clock label (.timeEnd) keeps from the icons. */ - margin-left: 12px; } -/* Same text treatment as the row's clock label (secondary tier, one step - under the body size); hover brightens one step to signal the cluster is - clickable. min-width 0 + hidden overflow let the one-line cluster shrink - with the chat column and ellipsize instead of widening it. */ +/* Same pill rules as the sibling `.action` icon buttons (28px hit height, + 15px glyph, tertiary → secondary on hover with the interactive hover + background), widened to carry the turn-total label. */ .trigger { + display: inline-flex; + align-items: center; + gap: 4px; min-width: 0; - padding: 0; + height: calc(28px + var(--dsh-content-font-delta, 0px)); + padding: 6px 8px; border: none; + border-radius: 28px; background: transparent; color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); white-space: nowrap; + cursor: pointer; +} + +/* A narrow column trims the label to an ellipsis instead of overflowing the + pill; the data glyph never shrinks. */ +.label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; - cursor: pointer; +} + +.trigger svg { + width: calc(15px + var(--dsh-content-font-delta, 0px)); + height: calc(15px + var(--dsh-content-font-delta, 0px)); + flex: none; } .trigger:hover, .trigger[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } -/* Same segment separators as the plain clock row's runTimeDot, so the - clickable and plain forms of the meta line space identically. */ -.dot { - margin: 0 5px; -} - +/* Portal surface: fixed in the viewport, left/top supplied inline from the + anchored-position clamp so the panel keeps its 12px viewport margin instead + of hanging off the trigger and clipping at the window edge. Portaled panels + layer above modal overlays (z 1000). */ .panel { - position: absolute; - bottom: calc(100% + 8px); - left: 0; - z-index: 100; + position: fixed; + z-index: 1100; box-sizing: border-box; - /* Size to the widest row so provider/model stays on one line, within a cap. */ + /* Size to the widest row so provider/model stays on one line, within a cap. + Both bounds yield to a viewport narrower than themselves (12px margins), + so a fixed floor cannot push the panel past the placement clamp. */ width: max-content; - min-width: 300px; + min-width: min(300px, calc(100vw - 24px)); max-width: min(440px, calc(100vw - 24px)); - padding: 12px; + padding: 16px; border: 1px solid var(--dsw-alias-border-inverted); border-radius: 12px; background: var(--dsw-specific-menu); @@ -61,6 +73,13 @@ cursor: default; } +/* Dialog heading, same size and weight as the total row it concludes with. */ +.title { + margin-bottom: 12px; + color: var(--dsw-alias-label-primary); + font-weight: 500; +} + .details { display: grid; grid-template-columns: minmax(76px, auto) minmax(0, 1fr); @@ -90,10 +109,16 @@ white-space: nowrap; } +/* Full-bleed rule above the total row: the grid's column gap would break a + per-cell border-top, so the line is its own row spanning both columns. */ +.separator { + grid-column: 1 / -1; + margin: 4px 0; + border-top: 1px solid var(--dsw-alias-border-l2); +} + .totalLabel, .details .totalValue { - padding-top: 8px; - border-top: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); font-weight: 500; } diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index 65709cebb8..79cbb6617b 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -1,27 +1,17 @@ -// Single-row Turn chrome: the finalized footer's whole meta line (clock, -// duration, turn total, throughput, TTFT, cache-hit) as one inline trigger -// whose click opens the per-Turn details dialog. +// Icon-row Turn-usage action: a data-icon pill labelled with the turn total +// (and the cache-hit rate when known) that click-opens the per-Turn details +// dialog. Sits right of the branch action in the tail's IconActions row. -import { Fragment, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { createPortal } from 'react-dom' +import { IconDataOutline16, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTokenUsage } from '../contract/chat-nodes.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { - formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond, -} from './message-chrome.ts' import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' -import { useCalendarDay } from './use-calendar-day.ts' import css from './TurnUsagePanel.module.css' export interface TurnUsagePanelProps { usage: TurnTokenUsage - /** Turn-close instant (unix epoch ms) shown as the line's leading clock. */ - time: number - /** Turn elapsed run time in ms, shown as `用时 6秒`; omitted when unrecorded. */ - runMs?: number | undefined - /** Turn first-step TTFT in ms, shown as `TTFT 1.2s`; omitted when unrecorded. */ - ttftMs?: number | undefined - /** Turn decode throughput, shown as `34 tok/s`; omitted when unrecorded. */ - tokensPerSecond?: number | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -34,21 +24,46 @@ function formatExactCount(value: number, t: ChatViewSlotProps['t']): string { return t('message.turnUsage.count', { count: formatExactTokens(value, t) }) } +/** Viewport margin the placement clamp keeps (the Menu portal margin). */ +const PANEL_MARGIN = 12 + +/** Distance between the trigger's top edge and the panel's bottom. */ +const PANEL_GAP = 8 + /** - * Whole-line clickable meta cluster with a click-open Turn-details dialog. - * @param props - Turn usage buckets, close time, optional timing metrics, locale seat. - * @returns The trigger and, while open, its anchored dialog. + * Unplaced portal panel: hidden but laid out so the clamp measures real + * dimensions (the `useAnchoredPosition` measure pass). */ -export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t }: TurnUsagePanelProps) { +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } + +/** + * Turn-usage IconActions pill with a click-open Turn-details dialog. + * @param props - Turn usage buckets and locale seat. + * @returns The icon-and-total trigger and, while open, its portaled dialog anchored above the trigger. + */ +export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { const [open, setOpen] = useState(false) const rootRef = useRef(null) - const day = useCalendarDay() + const panelRef = useRef(null) + + // Portal placement: the dialog is fixed above the trigger and clamped inside + // the viewport, so a trigger near the window edge cannot push it off-screen. + const pos = useAnchoredPosition({ + open, + anchorRef: rootRef, + panelRef, + side: 'top', + gap: PANEL_GAP, + margin: PANEL_MARGIN, + }) // Outside click / Escape close, one document listener while open (ContextMeter's pattern). useEffect(() => { if (!open) return const onPointerDown = (e: PointerEvent): void => { - if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return + if (!(e.target instanceof Node)) return + if (rootRef.current?.contains(e.target) === true) return + if (panelRef.current?.contains(e.target) === true) return setOpen(false) } const onKeyDown = (e: KeyboardEvent): void => { @@ -67,15 +82,6 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1) const total = formatCompactCount(usage.totalTokens, t) const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? '' - const clock = formatMessageClock(time, t, day) - const metrics: string[] = [clock] - if (runMs !== undefined) metrics.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) })) - metrics.push(t('message.turnUsage.consumed', { total })) - if (cacheHit !== null) metrics.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit })) - if (tokensPerSecond !== undefined) { - metrics.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) })) - } - if (ttftMs !== undefined) metrics.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })) return ( @@ -86,21 +92,22 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t aria-expanded={open} onClick={() => { setOpen(!open) }} > - {metrics.map((metric, index) => ( - - {index > 0 && ( - <> - {' '} - · - {' '} - - )} - {metric} - - ))} + + + {cacheHit === null + ? t('message.turnUsage.consumed', { total }) + : `${t('message.turnUsage.consumed', { total })} · ${t('message.turnUsage.cacheHitRate', { percent: cacheHit })}`} + - {open && ( -
+ {open && createPortal( +
+
{t('message.turnUsage.title')}
{routes !== '' && ( <> @@ -108,6 +115,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
{routes}
)} + {cacheHit !== null && ( + <> +
{t('message.turnUsage.cacheHit')}
+
{`${cacheHit}%`}
+ + )}
{t('message.turnUsage.input')}
{formatExactCount(usage.uncachedInputTokens, t)}
{usage.cacheReadTokens !== undefined && ( @@ -131,16 +144,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t )} - {cacheHit !== null && ( - <> -
{t('message.turnDetails.cacheHit')}
-
{`${cacheHit}%`}
- - )} +
{t('message.turnUsage.total')}
{formatExactCount(usage.totalTokens, t)}
-
+
, + document.body, )}
) diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 2caec97796..cb34f59c7c 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -91,18 +91,11 @@ export const zh = { 'message.ttft': '首 token {seconds}秒', 'message.tokensPerSecond': '{tps} tok/s', 'message.turnUsage.title': '本轮用量', - 'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%', - 'message.turnUsage.consumed': '本轮用量 {total}', + 'message.turnUsage.consumed': '用量 {total}', + 'message.turnUsage.cacheHitRate': '缓存命中 {percent}%', 'message.turnUsage.speed': '速度 {tps} tok/s', - 'message.turnUsage.cacheHitRate': '缓存命中率 {percent}%', - 'message.turnDetails.title': '本轮详情', - 'message.turnDetails.time': '时间', - 'message.turnDetails.duration': '用时', - 'message.turnDetails.ttft': '首 token', - 'message.turnDetails.speed': '速度', - 'message.turnDetails.usage': '用量', - 'message.turnDetails.cacheHit': '缓存命中率', 'message.turnUsage.model': '提供方 / 模型', + 'message.turnUsage.cacheHit': '缓存命中', 'message.turnUsage.input': '未缓存输入', 'message.turnUsage.cacheRead': '缓存读取', 'message.turnUsage.cacheWrite': '缓存写入', @@ -214,18 +207,11 @@ export const en = { 'message.ttft': 'TTFT {seconds}s', 'message.tokensPerSecond': '{tps} tok/s', 'message.turnUsage.title': 'Turn usage', - 'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%', - 'message.turnUsage.consumed': 'Turn usage {total}', - 'message.turnUsage.speed': '{tps} tok/s', + 'message.turnUsage.consumed': 'Usage {total}', 'message.turnUsage.cacheHitRate': 'Cache hit {percent}%', - 'message.turnDetails.title': 'Turn details', - 'message.turnDetails.time': 'Time', - 'message.turnDetails.duration': 'Duration', - 'message.turnDetails.ttft': 'TTFT', - 'message.turnDetails.speed': 'Speed', - 'message.turnDetails.usage': 'Usage', - 'message.turnDetails.cacheHit': 'Cache hit', + 'message.turnUsage.speed': '{tps} tok/s', 'message.turnUsage.model': 'Provider / model', + 'message.turnUsage.cacheHit': 'Cache hit', 'message.turnUsage.input': 'Uncached input', 'message.turnUsage.cacheRead': 'Cached input', 'message.turnUsage.cacheWrite': 'Cache write', diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 985a452fd6..1c795904b9 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -39,6 +39,12 @@ const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null const RETRY_ID = 'retry-fixture' as Extract['retryId'] +// Recency scans the whole transcript; a detached fixture is its own latest row. +const useDetachedChat: ChatNodeViewProps['useChat'] = bindSnapshotSelector({ + subscribe: () => () => {}, + getSnapshot: () => ({ order: [], nodes: new Map() }), +} as never) + interface MessageItemProps { readonly node: ConversationNode readonly t: ChatNodeViewProps['t'] @@ -62,7 +68,7 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) ? { ...node, referenceLabels } : node, } - const props = { node: viewNode, t: translate, renderMessageImages } as ChatNodeViewProps + const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps switch (node.kind) { case 'user': case 'steering': diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index b11c53e87b..a279c63c4d 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -13,7 +13,7 @@ const read = (name: string): string => function declarationsFrom(source: string, selector: string): string[] { const declarationText = source.replace(/\/\*[\s\S]*?\*\//g, ' ') - const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText) + const rule = new RegExp(`(?:^|[{}])\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText) if (rule === null) throw new Error(`no \`${selector}\` rule`) return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) } @@ -45,11 +45,12 @@ describe('chat flow font-size axis', () => { it('the message clock and action glyphs scale with the text they serve', () => { const actions = read('MessageIconActions.module.css') + // Both clocks read the secondary tier: the assistant tail's meta line + // (the whole-line usage trigger) and the user row's clock stay one step + // under the body size so the two rows match. expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', ])) - // The assistant tail's meta line (the whole-line usage trigger) sits one - // step under the body size; the user row's clock keeps the body size. expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([ 'font-size: var(--dsh-content-font-size-secondary, 13px)', ])) @@ -93,26 +94,27 @@ describe('chat flow font-size axis', () => { ])) }) - it('the whole-line trigger ellipsizes instead of widening the chat column', () => { - // The one-line meta cluster stays nowrap; min-width 0 lets the flex item - // shrink with the column and the hidden overflow trims to an ellipsis. + it('the usage pill sizes its glyph and hit height like the sibling action buttons', () => { + // The pill sits in the icon row right of the branch action; its data glyph + // and 28px hit height follow the same delta rule as `.action` so the row + // stays one height at every font size. const css = read('TurnUsagePanel.module.css') expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([ - 'min-width: 0', + 'height: calc(28px + var(--dsh-content-font-delta, 0px))', 'white-space: nowrap', + 'min-width: 0', + ])) + expect(declarationsFrom(css, '.trigger svg')).toEqual(expect.arrayContaining([ + 'width: calc(15px + var(--dsh-content-font-delta, 0px))', + 'height: calc(15px + var(--dsh-content-font-delta, 0px))', + ])) + // A narrow column trims the pill label to an ellipsis instead of letting + // it overflow or widen the chat column. + expect(declarationsFrom(css, '.label')).toEqual(expect.arrayContaining([ + 'min-width: 0', 'overflow: hidden', 'text-overflow: ellipsis', ])) - expect(declarationsFrom(css, '.root')).toEqual(expect.arrayContaining(['min-width: 0'])) - }) - - it('the clickable and plain meta lines keep identical inset and separator spacing', () => { - const panel = read('TurnUsagePanel.module.css') - const actions = read('MessageIconActions.module.css') - expect(declarationsFrom(panel, '.dot')).toEqual(['margin: 0 5px']) - expect(declarationsFrom(actions, '.runTimeDot')).toEqual(['margin: 0 5px']) - expect(declarationsFrom(panel, '.root')).toEqual(expect.arrayContaining(['margin-left: 12px'])) - expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining(['padding-left: 12px'])) }) it('non-latest turn tails hide the whole actions row until hover or focus', () => { 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 bf74fc890c..ef60154e61 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1562,18 +1562,18 @@ describe('ChatView', () => { }]]), }) const view = render() - // First-step ttft (1.2s) plus 100 tokens over 5s of decode: the whole - // meta line (clock, run time, usage metrics) is one clickable trigger. - const trigger = view.getByRole('button', { name: /本轮用量 10\.1K tok/ }) - expect(trigger.textContent).toMatch( - /^.+ · 用时 19秒 · 本轮用量 10\.1K tok · 缓存命中率 49\.4% · 速度 20 tok\/s · 首 token 1\.2秒$/, - ) + // First-step ttft (1.2s) plus 100 tokens over 5s of decode stay plain + // text; the usage pill in the icon row is the only clickable trigger. + const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ }) + expect(trigger.textContent).toBe('用量 10.1K tok · 缓存命中 49.4%') + expect(view.getByText(/用时 19秒/).textContent) + .toMatch(/^.+ · 用时 19秒 · 速度 20 tok\/s · 首 token 1\.2秒$/) expect(view.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) const dialog = view.getByRole('dialog') expect(dialog.getAttribute('aria-label')).toBe('本轮用量') + expect(dialog.textContent).toContain('缓存命中49.4%') expect(dialog.textContent).toContain('未缓存输入5,060 tok') - expect(dialog.textContent).toContain('缓存命中率49.4%') expect(dialog.textContent).toContain('总计10,100 tok') }) @@ -1589,7 +1589,10 @@ describe('ChatView', () => { turnEnds: new Map([[1, 20]]), }) const view = render() - expect(view.queryByText(/首 token|tok\/s/)).toBeNull() + // Timing facts stay visible as plain text, but with no usage in the + // window there is no pill to click. + expect(view.getByText(/首 token/)).toBeTruthy() + expect(view.queryByRole('button', { name: /用量/ })).toBeNull() }) it('withholds ttft and throughput while the turn is still running', () => { @@ -1607,7 +1610,7 @@ describe('ChatView', () => { expect(view.queryByText(/首 token|tok\/s/)).toBeNull() }) - it('user rows scope the hover clock; turn tails gate the whole row by recency', () => { + it('user rows and turn tails both gate the whole actions row by recency', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), @@ -1622,13 +1625,15 @@ describe('ChatView', () => { turnEnds: new Map([[1, 3], [2, 6]]), }) const view = render() - // Only the user rows keep the time-hover scope; assistant tails moved to - // the recency-gated whole-row reveal. - expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2) - const tails = view.container.querySelectorAll('[data-actions-reveal]') + // The last user-authored row and the latest turn's tail stay shown; + // every earlier row of either kind reveals on hover. + const tails = view.container.querySelectorAll('[data-turn-tail]') expect(new Map([...tails].map(tail => [ tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'), ]))).toEqual(new Map([['1', 'hover'], ['2', 'always']])) + const userRows = [...view.container.querySelectorAll('[data-actions-reveal]')] + .filter(row => row.getAttribute('data-turn-tail') === null) + expect(userRows.map(row => row.getAttribute('data-actions-reveal'))).toEqual(['hover', 'always']) }) it('the run-time label is withheld when the turn start is outside the window', () => { diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx index d99fc0598f..e97b88af10 100644 --- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx @@ -10,13 +10,10 @@ import { en } from '../src/client/locale.ts' const t = makeTranslate(en, commonEn) -// Today at 22:08 local, so formatMessageClock renders the bare `HH:mm` form. -const TIME = new Date().setHours(22, 8, 0, 0) - afterEach(cleanup) describe('TurnUsagePanel', () => { - it('exposes the whole meta line inline and opens the usage dialog on click', () => { + it('shows an icon-and-total pill and opens the usage dialog on click', () => { const usage: TurnTokenUsage = { uncachedInputTokens: 5_060, cacheReadTokens: 4_940, @@ -26,12 +23,11 @@ describe('TurnUsagePanel', () => { totalTokens: 15_800, routes: [{ provider: 'deepseek', model: 'deepseek-chat' }], } - const view = render( - , - ) + const view = render() const trigger = view.getByRole('button') - expect(trigger.textContent).toBe('22:08 · Ran for 13s · Turn usage 15.8K tok · Cache hit 49.4% · 107 tok/s · TTFT 2s') + expect(trigger.textContent).toBe('Usage 15.8K tok · Cache hit 49.4%') + expect(trigger.querySelector('svg')).not.toBeNull() expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') expect(trigger.getAttribute('aria-expanded')).toBe('false') expect(view.queryByRole('dialog')).toBeNull() @@ -40,14 +36,17 @@ describe('TurnUsagePanel', () => { expect(trigger.getAttribute('aria-expanded')).toBe('true') const dialog = view.getByRole('dialog') expect(dialog.getAttribute('aria-label')).toBe('Turn usage') + // Portaled out of the trigger's row, with a visible heading above the rows. + expect(dialog.parentElement).toBe(document.body) + expect(dialog.firstChild?.textContent).toBe('Turn usage') const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement expect(details).toBeTruthy() expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat') + expect(details.textContent).toContain('Cache hit49.4%') expect(details.textContent).toContain('Uncached input5,060 tok') expect(details.textContent).toContain('Cached input4,940 tok') expect(details.textContent).toContain('Cache write0 tok') expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)') - expect(details.textContent).toContain('Cache hit49.4%') expect(details.textContent).toContain('Total15,800 tok') }) @@ -57,33 +56,34 @@ describe('TurnUsagePanel', () => { outputTokens: 30, totalTokens: 150, } - const view = render() + const view = render() const trigger = view.getByRole('button') - expect(trigger.textContent).toBe('22:08 · Turn usage 150 tok') - expect(view.queryByText(/Cache hit/)).toBeNull() - expect(view.queryByText(/TTFT/)).toBeNull() - expect(view.queryByText(/tok\/s/)).toBeNull() + expect(trigger.textContent).toBe('Usage 150 tok') fireEvent.click(trigger) expect(view.queryByText('Provider / model')).toBeNull() + expect(view.queryByText('Cache hit')).toBeNull() expect(view.queryByText('Cached input')).toBeNull() expect(view.queryByText('Cache write')).toBeNull() expect(view.queryByText(/reasoning/)).toBeNull() }) - it('keeps a partial cache hit below 100 and closes on Escape or outside pointerdown', () => { + it('keeps a partial cache hit below 100 in the dialog and closes on Escape or outside pointerdown', () => { const usage: TurnTokenUsage = { uncachedInputTokens: 1, cacheReadTokens: 999, outputTokens: 100, totalTokens: 1_100, } - const view = render() + const view = render() const trigger = view.getByRole('button') - expect(trigger.textContent).toBe('22:08 · Turn usage 1.1K tok · Cache hit 99.9%') + // The pill carries the compact total and cache-hit rate; exact token + // counts stay in the dialog. + expect(trigger.textContent).toBe('Usage 1.1K tok · Cache hit 99.9%') fireEvent.click(trigger) - expect(view.getByRole('dialog')).toBeTruthy() + const dialog = view.getByRole('dialog') + expect(dialog.textContent).toContain('Cache hit99.9%') fireEvent.keyDown(document, { key: 'Escape' }) expect(view.queryByRole('dialog')).toBeNull() expect(trigger.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-primitives/src/useAnchoredPosition.ts b/packages/client/ui-primitives/src/useAnchoredPosition.ts index 07feb71d2f..90404bd639 100644 --- a/packages/client/ui-primitives/src/useAnchoredPosition.ts +++ b/packages/client/ui-primitives/src/useAnchoredPosition.ts @@ -3,10 +3,10 @@ * * A portaled panel is positioned from its anchor's viewport rect, which stops * being true the moment anything scrolls or the window resizes. This owns that - * one concern: measure the anchor, offset the panel below it, clamp the result - * inside the viewport, and re-run on scroll (capture phase, so scrollers nested - * inside the page are caught too), on resize, and on the panel's own size - * changes while the element is open. + * one concern: measure the anchor, offset the panel below or above it, clamp + * the result inside the viewport, and re-run on scroll (capture phase, so + * scrollers nested inside the page are caught too), on resize, and on the + * panel's own size changes while the element is open. * @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition */ @@ -20,7 +20,9 @@ export interface AnchoredPositionOptions { anchorRef: RefObject /** The floating element, measured so the clamp uses real dimensions. */ panelRef: RefObject - /** Distance kept between the anchor's bottom edge and the panel's top. */ + /** Which anchor edge the panel hangs from: below it (`bottom`, the default) or above it (`top`). */ + side?: 'top' | 'bottom' + /** Distance kept between the anchor edge named by `side` and the panel. */ gap: number /** Distance kept between the panel and each viewport edge. */ margin: number @@ -28,11 +30,11 @@ export interface AnchoredPositionOptions { /** * Track an anchor and return the panel's fixed coordinates. - * @param options - the open state, the two refs, and the gap/margin distances. + * @param options - the open state, the two refs, the placement side, and the gap/margin distances. * @returns `left`/`top` for the panel, or `null` before the first measurement. */ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null { - const { open, anchorRef, panelRef, gap, margin } = options + const { open, anchorRef, panelRef, side = 'bottom', gap, margin } = options const [position, setPosition] = useState(null) useLayoutEffect(() => { if (!open) { @@ -49,7 +51,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper const width = panel?.offsetWidth ?? 0 const height = panel?.offsetHeight ?? 0 let left = rect.left - let top = rect.bottom + gap + let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin) if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin) /* v8 ignore stop */ @@ -76,6 +78,6 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, anchorRef, panelRef, gap, margin]) + }, [open, anchorRef, panelRef, side, gap, margin]) return position } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7aec63d5d..0cd0255ae3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2053,9 +2053,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-commands: dependencies: diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md index 32f481d4ff..7d470fc968 100644 --- a/snapshots/web/code-mode-round/ui.expected.md +++ b/snapshots/web/code-mode-round/ui.expected.md @@ -49,7 +49,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 72988cae07..6a97b82d8b 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."': - img - img diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index dcba687138..11f347f1d1 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."': - img - img diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 0c5ebce2fb..c992c0db80 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': - img - img diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 1918a0e3ca..b6c5cf91bc 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': - img - img diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 92222fbcd8..1b72b70fe2 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -44,7 +44,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 9085361adc..49dc055318 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index d943f181c2..79e0af28fb 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -97,7 +97,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "System prompt": - img - img @@ -217,7 +217,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "Back to bottom": - img - textbox "Message or run a task... / commands, @ files or sessions" diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 8b0b4737dc..32ac5cc62e 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -42,7 +42,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "System prompt": - img - img @@ -107,7 +107,7 @@ - button "Branch into a new conversation": - img - tooltip "Branch into a new conversation" -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 31ff786841..9a45f772a8 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 8031b8de59..358fb187cc 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 04c061962c..c341b22062 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -38,7 +38,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 850d151cb9..695af671e3 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index fb4fa71c14..826509803f 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation" [disabled]: - img -- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} +- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "Read a.txt": - img - img diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 2d104f8e54..f470df2a28 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -33,7 +33,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index c6b3239426..fa0facf822 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -54,7 +54,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 9c6a0e09e5..8135c83a0f 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -34,7 +34,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index da262e8712..7c0a05c6c7 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -46,7 +46,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 67524dbc79..252ca47499 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -28,7 +28,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index ccacb6dd7b..66769e0488 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -29,7 +29,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - button "2 queued messages" [expanded] - list: - listitem: diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 5fc1034229..8f6b5b5e95 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -47,7 +47,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} +- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index d8a4bdf483..17eb33963d 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -47,7 +47,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} +- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 38e1f2a261..28e2a90ec2 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -47,7 +47,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index b5819db45d..5167001259 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -29,7 +29,7 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} +- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index ce5851b7e9..21e4c5c810 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -47,7 +47,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 44e93167a3..f7a0b9b006 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -39,7 +39,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index a650f1e211..62ed474ccf 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 71707d3523..ac152fe9ed 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -44,7 +44,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "System prompt": - img - img @@ -68,7 +68,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 6474f5f98a..7212aa8d18 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - button "System prompt": - img - img @@ -56,7 +56,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index fd12bf82ca..c7dd77a4a3 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -20,10 +20,6 @@ - text: 1 tool call · 1 message - img - paragraph: DONE -- button "Turn usage 15.8K tok · Cache hit 49.7%": - - img - - img - - text: Turn usage 15.8K tok · Cache hit 49.7% - button "Copy": - img - button "Good response": @@ -32,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Usage 15.8K tok · Cache hit 49.7%": + - img + - text: Usage 15.8K tok · Cache hit 49.7% +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index e313ab41fc..f8964fa6d7 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -33,10 +33,6 @@ - img - text: Bash Print alpha to stdout - paragraph: DONE -- button "Turn usage 15.8K tok · Cache hit 49.7%": - - img - - img - - text: Turn usage 15.8K tok · Cache hit 49.7% - button "Copy": - img - button "Good response": @@ -45,7 +41,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Usage 15.8K tok · Cache hit 49.7%": + - img + - text: Usage 15.8K tok · Cache hit 49.7% +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index a711875317..90d71c1a86 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -30,7 +30,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index ba9a3f5c1f..466d21613a 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -20,19 +20,6 @@ - text: 1 tool call · 1 message - img - paragraph: DONE -- button "Turn usage 15.8K tok · Cache hit 49.7%" [expanded]: - - img - - text: Turn usage 15.8K tok · Cache hit 49.7% -- term: Provider / model -- definition: deepseek-official/deepseek-v4-flash -- term: Uncached input -- definition: 7,891 tok -- term: Cached input -- definition: 7,808 tok -- term: Output -- definition: 112 tok (42 tok reasoning) -- term: Total -- definition: 15,811 tok - button "Copy": - img - button "Good response": @@ -41,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Usage 15.8K tok · Cache hit 49.7%" [expanded]: + - img + - text: Usage 15.8K tok · Cache hit 49.7% +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 3b9f58931d..5ceba38e31 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -36,7 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 027175c4d6..7f47cb6d63 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -36,4 +36,4 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} From 61f65ffd48d42f14c32d6ceef3b837d86c1332f4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 23:27:14 +0800 Subject: [PATCH 019/429] test: allow Windows title diagnostic latency --- .../test-support/session-snapshot/tests/harness.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 647c67d662..4c88111de5 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -66,6 +66,8 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s } const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] +// A Windows coverage shard can spend more than 20ms harvesting logs before vi.waitFor records the diagnostic error. +const titleDiagnosticTimeoutMs = process.platform === 'win32' ? 5_000 : 20 it('keeps scenario-owned snapshot spill root length stable across platforms', () => { const fixtureFile = '/fixtures/scenario/session.jsonl' @@ -1057,11 +1059,11 @@ describe('runScenario', () => { steps: [ ...boot, { op: 'promptAndCancel', text: 'hang' }, - { op: 'waitForTitleAfterTurnEnd', timeoutMs: 20 }, + { op: 'waitForTitleAfterTurnEnd', timeoutMs: titleDiagnosticTimeoutMs }, ], }, { agent: AGENT, mode: 'replay', fixtureFile }, - )).rejects.toThrow(/did not persist session\/title after turn\/end within 20ms/) + )).rejects.toThrow(new RegExp(`did not persist session/title after turn/end within ${titleDiagnosticTimeoutMs}ms`)) }) it('waitForEventAfterTurnEnd holds the app for a typed post-boundary record and times out otherwise', { timeout: 20_000 }, async () => { From 5ba375fd889ed73b414cfc7d4c321ad2f9b23e61 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 00:37:19 +0800 Subject: [PATCH 020/429] feat(client): hover swim morph for the hero fish --- ...08-12-hero-fish-hover-swim-morph.i18n.yaml | 6 ++ .../2026-08-12-hero-fish-hover-swim-morph.md | 27 +++++++ ...026-08-12-hero-fish-hover-swim-morph.zh.md | 27 +++++++ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- .../client/ui-brand-official/README.i18n.yaml | 4 +- packages/client/ui-brand-official/README.md | 10 +-- .../client/ui-brand-official/README.zh.md | 10 +-- .../client/ui-brand-official/package.json | 5 +- .../ui-brand-official/src/client/Brand.tsx | 7 +- .../ui-brand-official/src/client/index.ts | 15 ++-- .../tests/browser-plugin.client.spec.tsx | 15 ++-- .../client/ui-brand-official/tsconfig.json | 3 - .../src/client/skeleton/EmptyHero.tsx | 71 +++++++++++++++++-- .../src/client/skeleton/HeroShell.module.css | 27 ++++--- .../tests/skeleton.client.spec.tsx | 10 +-- .../client/ui-primitives/src/FishLogo.tsx | 12 +++- packages/client/ui-primitives/src/index.ts | 2 +- .../src/client/slot-catalog.ts | 6 +- pnpm-lock.yaml | 3 - 21 files changed, 200 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md create mode 100644 .agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml new file mode 100644 index 0000000000..47ca707476 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.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-12-hero-fish-hover-swim-morph.md +2026-08-12-hero-fish-hover-swim-morph.md: 2ae090f2e9640fc68091e918a644b4f793d1a8f4 +2026-08-12-hero-fish-hover-swim-morph.zh.md: eac0047a083d410b1f538954088a29ef721fe054 diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md new file mode 100644 index 0000000000..2ae090f2e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md @@ -0,0 +1,27 @@ +# Agent Note: Hero fish hover swim morph + +Status: implemented + +English | [中文](2026-08-12-hero-fish-hover-swim-morph.zh.md) + +## Problem + +Hovering the New Session hero fish (`EmptyHero.tsx` in `dsh-client-ui-conversation`) played a one-shot rigid CSS sway of the whole svg. The user wanted the whale to visibly swim — the tail wagging and the mouth curve lifting — which requires deforming the path geometry itself. CSS transforms cannot bend a subset of a path's curves, and the logo ships as one `FISH_LOGO_PATH` string in `dsh-client-ui-primitives`. + +## Decision + +Real curve deformation via SMIL `` cycling `rest → tail-up → rest → tail-down → rest` on the same 1.6s period as the CSS sway, which becomes continuous (`infinite`) for as long as the pointer stays. The two morph targets are generated programmatically (`/tmp`-run script, not checked in): parse `FISH_LOGO_PATH`'s absolute M/C/L/Z commands, rotate the tail region about a pivot with smoothstep falloff weights, bend the mouth/fin swoosh vertically with weight-squared falloff from its body anchor (a smile lift, not a rigid swing — rigid rotation read as detached), and emit structure-identical command strings SMIL can interpolate. The baked path constants live next to the component with the generation parameters documented. SMIL cannot ride CSS media queries, so a `hovering` state gated by `matchMedia('(prefers-reduced-motion: reduce)')` mounts the morph, while the CSS sway sits under `@media (hover: hover) and (prefers-reduced-motion: no-preference)`. + +The morphing fish reaches the hero as the fallback of the `conversation.hero.brand.mark` slot; no shipped package occupies it — `dsh-client-ui-brand-official` fills only the sidebar slots, since a feature plugin may not value-import `HeroFish` across packages ([client cross-package rule](../process/2026-08-23-client-cross-package-value-dependencies.md)) and the fallback already is the official mark. `FISH_LOGO_PATH` and `FISH_LOGO_VIEWBOX` are exported from `dsh-client-ui-primitives` for consumers that compose their own svg around the same geometry. + +## Alternatives considered + +**Vector-tool path editing for the morphs.** No interactive tool in the loop; programmatic weighted deformation was chosen because it guarantees the identical command structure SMIL `d` interpolation requires and makes amplitudes reviewable numbers. + +**Blowhole spout on hover.** Shipped first as three rising beads (richer whale-blow fountain, bubble-cluster, and ballistic-arc shapes were built and rejected in review), then removed entirely at the user's request: hover keeps only the shape morph and sway. + +**Occupying the hero slot with the official mark.** The previous arrangement; rejected because the static occupant shadowed the animated fallback, and animating the occupant instead would need the forbidden cross-package value import. + +## Consequences + +The hover swim is decorative (`aria-hidden`) and reduced-motion-safe (static logo on hover). The sway CSS targets the stationary `.fishHitbox` wrapper, so a slot occupant would sway too; the body morph lives only in the fallback `HeroFish`. Coverage is the component suite (`skeleton.client.spec.tsx`) asserting render states; the keyless snapshot harness records transcripts, not browser animation, so visual verification stays manual. Regenerating the morph targets requires re-running the (uncommitted) deformation script against `FISH_LOGO_PATH`; if the logo geometry ever changes, the baked constants must be regenerated with it. diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md new file mode 100644 index 0000000000..eac0047a08 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md @@ -0,0 +1,27 @@ +# Agent Note:主页鲸鱼 hover 游动变形 + +Status: implemented + +[English](2026-08-12-hero-fish-hover-swim-morph.md) | 中文 + +## 问题 + +hover New Session 主页的鲸鱼(`dsh-client-ui-conversation` 的 `EmptyHero.tsx`)原本只播放一次整个 svg 的刚性 CSS 摇摆。用户希望鲸鱼有真实的游动感——尾巴摆动、嘴巴曲线上扬,这要求对路径几何本身做变形。CSS transform 无法弯曲路径中的部分曲线,且 logo 以单一 `FISH_LOGO_PATH` 字符串存放在 `dsh-client-ui-primitives`。 + +## 决定 + +通过 SMIL `` 做真实曲线变形,按与 CSS 摇摆相同的 1.6s 周期循环 `静止 → 尾上摆 → 静止 → 尾下压 → 静止`;CSS 摇摆改为持续循环(`infinite`),指针停留多久就游多久。两个变形目标由程序生成(在 `/tmp` 运行的脚本,未入库):解析 `FISH_LOGO_PATH` 的绝对 M/C/L/Z 命令,尾部区域绕支点做带 smoothstep 衰减权重的旋转,嘴巴/鳍的内侧曲线以距身体锚点的权重平方做竖直弯曲(微笑式上扬,而非刚性摆动——刚性旋转看起来与身体脱节),并输出结构完全一致、SMIL 可插值的命令串。烘焙出的路径常量与组件放在一起,并在注释中记录生成参数。SMIL 无法响应 CSS 媒体查询,因此用经 `matchMedia('(prefers-reduced-motion: reduce)')` 判定的 `hovering` 状态控制变形挂载,CSS 摇摆则在 `@media (hover: hover) and (prefers-reduced-motion: no-preference)` 之下。 + +变形鲸鱼以 `conversation.hero.brand.mark` slot 的 fallback 身份进入主页;没有任何发布包占据该 slot——`dsh-client-ui-brand-official` 只填充侧栏槽位,因为 feature 插件不得跨包 value-import `HeroFish`([client 跨包规则](../process/2026-08-23-client-cross-package-value-dependencies.zh.md)),而 fallback 本身就是官方标志。`FISH_LOGO_PATH` 与 `FISH_LOGO_VIEWBOX` 从 `dsh-client-ui-primitives` 导出,供围绕同一几何自行组装 svg 的消费方使用。 + +## 考虑过的替代方案 + +**用矢量工具编辑路径做变形。** 流程中没有可交互的工具;选择程序化加权变形,因为它保证 SMIL `d` 插值所要求的完全一致的命令结构,且振幅是可评审的数字。 + +**hover 气孔喷水。** 最初以三颗上升水珠的形式上线(更复杂的鲸鱼喷泉、泡泡簇、弹道水花造型都做出来后在评审中被否),随后按用户要求整体移除:hover 只保留形状变形与摇摆。 + +**让官方标志占据主页 slot。** 即先前的安排;否决,因为静态 occupant 会遮住动画 fallback,而给 occupant 加动画又需要被禁止的跨包 value import。 + +## 影响 + +hover 游动是纯装饰(`aria-hidden`)且对 reduced-motion 安全(hover 保持静态 logo)。摇摆 CSS 作用于外层静止的 `.fishHitbox`,因此换成 slot occupant 也会摇摆;身体变形只存在于 fallback 的 `HeroFish` 中。覆盖由组件测试(`skeleton.client.spec.tsx`)断言渲染状态;keyless 快照体系记录的是对话转录而非浏览器动画,视觉验证仍需人工。重新生成变形目标需要对 `FISH_LOGO_PATH` 重跑(未入库的)变形脚本;若 logo 几何将来变化,烘焙常量必须随之重新生成。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 445c9ac17a..e169695f31 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: b48c197299f1afbf53bf60962f544148f7f6defc -module-graph.zh.md: 1f85ab06c3ca98d6060dab400d2bf8dbfbda8cc5 +module-graph.md: 087abe4f43dd75d8b4e0390f2e78aeaf768048fc +module-graph.zh.md: 2a1e8fcef511011e82821856dcd0e185952da3eb diff --git a/docs/module-graph.md b/docs/module-graph.md index b48c197299..087abe4f43 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1443,7 +1443,6 @@ flowchart TD 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 @@ -1929,7 +1928,7 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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-brand-official`](../packages/client/ui-brand-official) | `client` | [`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` | [`api-remotes`](../packages/api/remotes), [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1f85ab06c3..2a1e8fcef5 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1445,7 +1445,6 @@ flowchart TD 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 @@ -1931,7 +1930,7 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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-brand-official`](../packages/client/ui-brand-official) | `client` | [`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` | [`api-remotes`](../packages/api/remotes), [`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) | diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml index e29dcc623f..4a60183c6e 100644 --- a/packages/client/ui-brand-official/README.i18n.yaml +++ b/packages/client/ui-brand-official/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-brand-official/README.md -README.md: 03712e826fb415dd6fb3125c06865a2402342948 -README.zh.md: 94e8a30bb11abd468a59cc80b6745d5d929a1e23 +README.md: 6b613db7c302e77f4bbcd93468e712beb5cf2a61 +README.zh.md: a01f659dcd76b98b94607c764988b5295e761b76 diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md index 03712e826f..6b613db7c3 100644 --- a/packages/client/ui-brand-official/README.md +++ b/packages/client/ui-brand-official/README.md @@ -1,5 +1,5 @@ --- -description: "Official DeepSeek Harness brand occupants for the sidebar and conversation hero, active only in official builds; for users and maintainers choosing or replacing brand presentation." +description: "Official DeepSeek Harness brand occupants for the sidebar, active only in official builds; for users and maintainers choosing or replacing brand presentation." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package fills the browser brand slots — `sidebar.brand.mark`, `sidebar.brand.name`, and `conversation.hero.brand.mark` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. Choose it when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests. +This package fills the sidebar brand slots — `sidebar.brand.mark` and `sidebar.brand.name` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. The conversation hero slot (`conversation.hero.brand.mark`) stays unoccupied in every build: its declaring package renders the animated hero fish (hover swim morph) as the fallback, and the official brand is that fish. Choose this package when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests. ## Table of Contents @@ -29,11 +29,11 @@ Mount this plugin in the browser roster of a deployment whose identity is DeepSe ### Choosing the profile -`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar and the mark in the conversation hero; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The plugin still loads and validates in both cases; only the registration is profile-gated. +`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The conversation hero shows the animated hero fish from `dsh-client-ui-conversation` regardless of profile, because that fallback is already the official mark. The plugin still loads and validates in both cases; only the registration is profile-gated. ### Replacing the brand -A deployment with its own identity leaves this package out and composes another package that occupies the same three slots. Occupying a slot is the only composition route; there is no brand configuration surface here. +A deployment with its own identity leaves this package out and composes another package that occupies the sidebar slots — and the hero slot, which this package leaves on its fallback. Occupying a slot is the only composition route; there is no brand configuration surface here. ----- @@ -43,7 +43,7 @@ A deployment with its own identity leaves this package out and composes another
Implementation internals — click to expand -The three occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar and conversation declarations, so the set works whether this row activates before or after the declarers, withdraws all three occupants when either declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system. +The two occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar declaration, so the set works whether this row activates before or after the declarer, withdraws both occupants when the declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md index 94e8a30bb1..a01f659dcd 100644 --- a/packages/client/ui-brand-official/README.zh.md +++ b/packages/client/ui-brand-official/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向侧栏与会话首屏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。" +description: "面向侧栏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向浏览器品牌槽位——`sidebar.brand.mark`、`sidebar.brand.name` 与 `conversation.hero.brand.mark`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。当部署身份就是 DeepSeek 自身时选择它;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。 +本包向侧栏品牌槽位——`sidebar.brand.mark` 与 `sidebar.brand.name`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。会话首屏槽位(`conversation.hero.brand.mark`)在所有构建中都保持无填充:其声明包以动画首屏鱼(悬停游动形变)作为回退渲染,而官方品牌正是这条鱼。当部署身份就是 DeepSeek 自身时选择本包;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。 ## 目录 @@ -29,11 +29,11 @@ kind: "package-reference" ### 选择 profile -`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称、在会话首屏显示标志;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。 +`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。会话首屏无论 profile 如何都显示来自 `dsh-client-ui-conversation` 的动画首屏鱼,因为这个回退本身就是官方标志。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。 ### 替换品牌 -自有身份的部署不组合本包,而是组合另一个占据相同三个槽位的包。占据槽位是唯一的组合路径;这里不存在任何品牌配置面。 +自有身份的部署不组合本包,而是组合另一个占据侧栏槽位——以及本包留给回退的首屏槽位——的包。占据槽位是唯一的组合路径;这里不存在任何品牌配置面。 ----- @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -三个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏与会话声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;任一声明消失时全部三个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在槽位系统之内。 +两个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;声明消失时两个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在槽位系统之内。
diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 33180942fe..da237b0861 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", - "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots", + "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -45,14 +44,12 @@ }, "license": "MIT", "peerDependencies": { - "@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-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", diff --git a/packages/client/ui-brand-official/src/client/Brand.tsx b/packages/client/ui-brand-official/src/client/Brand.tsx index 4e0a60fd26..9ac6dd7ce2 100644 --- a/packages/client/ui-brand-official/src/client/Brand.tsx +++ b/packages/client/ui-brand-official/src/client/Brand.tsx @@ -1,16 +1,13 @@ import { BrandWordmark, FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' -import type { HeroBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SidebarBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client' -type OfficialBrandMarkProps = HeroBrandMarkOwnerProps & SidebarBrandMarkOwnerProps - /** * Render the official mark with the presentation requested by its host surface. * @param props - Host-supplied mark presentation. * @returns the official whale mark. */ -export function OfficialBrandMark({ size, className }: OfficialBrandMarkProps) { - return +export function OfficialBrandMark({ size }: SidebarBrandMarkOwnerProps) { + return } /** diff --git a/packages/client/ui-brand-official/src/client/index.ts b/packages/client/ui-brand-official/src/client/index.ts index 237272bd1d..332a5f6007 100644 --- a/packages/client/ui-brand-official/src/client/index.ts +++ b/packages/client/ui-brand-official/src/client/index.ts @@ -1,6 +1,5 @@ /** Official DeepSeek Harness occupants for the generic browser-brand slots. */ 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' @@ -9,16 +8,16 @@ import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx' export const inject = ['slots'] /** - * Fill every shipped brand slot as one declaration-aware registration set. + * Fill the sidebar brand slots as one declaration-aware registration set. The + * conversation hero stays on its declaring package's animated fish fallback, + * so the official build registers nothing there. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return ctx.slots.inject('sidebar.brand.mark', () => - ctx.slots.inject('sidebar.brand.name', () => - ctx.slots.inject('conversation.hero.brand.mark', function* () { - yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark) - yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName) - yield ctx.slots.register({ name: 'conversation.hero.brand.mark' }, OfficialBrandMark) - }))) + ctx.slots.inject('sidebar.brand.name', function* () { + yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark) + yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName) + })) } 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 8fa5ca8056..36e8c62488 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 @@ -14,16 +14,17 @@ afterEach(() => { const HOLES = [ 'sidebar.brand.mark', 'sidebar.brand.name', - 'conversation.hero.brand.mark', ] as const +const HERO_HOLE = 'conversation.hero.brand.mark' + async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const slots = ctx.get('slots') as SlotRegistry const declareHoles = () => slots.register({ name: 'root', - children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + children: Object.fromEntries([...HOLES, HERO_HOLE].map(name => [name, { kind: 'single', scope: 'root' }])), } as never, () => null) const disposeHoles = declare ? declareHoles() : undefined return { ctx, slots, declareHoles, disposeHoles } @@ -65,14 +66,20 @@ describe('official browser-brand plugin', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) + it('leaves the conversation hero on its declaring fallback even in official builds', async () => { + vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official') + const subject = await bench() + await subject.ctx.plugin({ inject: [...inject], apply }).await() + expect(subject.slots.entries(HERO_HOLE)).toHaveLength(0) + }) + it('renders the official name independently from both requested mark sizes', () => { const name = render() expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24') name.unmount() - const mark = render() + const mark = render() expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34') - expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark') mark.rerender() expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24') }) diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json index 0ba9d872dd..479e3ad74a 100644 --- a/packages/client/ui-brand-official/tsconfig.json +++ b/packages/client/ui-brand-official/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../ui-renderer" }, - { - "path": "../ui-conversation" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 519b1b95a4..6cbf63a20b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -1,10 +1,10 @@ // The composer remains in ConversationRoot so switching out of the blank-draft // phase does not remount its textarea. -import { useId } from 'react' +import { useId, useState } from 'react' import type { ReactNode, RefObject } from 'react' import { - FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, + FISH_LOGO_PATH, FISH_LOGO_VIEWBOX, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ConversationSlotProps } from '../contract/slots.ts' @@ -105,6 +105,66 @@ export interface HeroShellProps { children?: ReactNode } +/* Hover swim morph targets: the resting FISH_LOGO_PATH with weighted + regional deformation baked in (generated programmatically — parse the + path's absolute M/C/L/Z commands, displace points with smoothstep falloff + weights, emit the same command structure so SMIL can interpolate `d` + between them). Tail: rotation about (15.6, 5.2) with weight growing toward + the tail tip (x>15, y<8.5) — UP -7°, DOWN +6°. Mouth/fin swoosh: a bend, + not a rotation — vertical lift with weight-squared falloff from the body + anchor (14.1, 15.0), so the near end stays seated and the mouth corner + sweeps most, a smile lift (UP -0.7 units at the tip, DOWN +0.5). The eye + subpaths carry zero weight and stay fixed. */ +const HERO_SWIM_UP_PATH = + 'M22.403 0.567C22.145 0.477 22.068 0.718 21.939 0.85C21.895 0.893 21.86 0.947 21.824 0.997C21.515 1.421 21.13 1.721 20.591 1.77C19.829 1.867 19.221 2.244 18.712 2.958C18.535 2.227 18.116 1.839 17.516 1.626C17.203 1.506 16.887 1.379 16.663 1.064C16.508 0.839 16.462 0.581 16.383 0.329C16.332 0.176 16.283 0.02 16.121 -0.002C15.944 -0.029 15.875 0.133 15.805 0.269C15.52 0.822 15.408 1.43 15.42 2.046C15.449 3.432 16.031 4.532 17.202 5.274C17.337 5.356 17.374 5.445 17.335 5.582C17.261 5.862 17.169 6.134 17.086 6.413C17.032 6.59 16.952 6.63 16.764 6.558C16.118 6.301 15.562 5.909 15.074 5.433C14.248 4.633 13.5 3.751 12.568 3.06C12.349 2.898 12.13 2.748 11.903 2.605C10.952 1.682 12.028 0.923 12.277 0.833C12.537 0.739 12.367 0.416 11.526 0.42C10.684 0.424 9.914 0.706 8.933 1.081C8.789 1.138 8.638 1.179 8.484 1.213C7.593 1.044 6.668 1.006 5.702 1.115C3.883 1.318 2.43 2.178 1.362 3.646C0.079 5.41 -0.223 7.415 0.147 9.506C0.535 11.71 1.66 13.535 3.389 14.962C5.181 16.441 7.246 17.166 9.601 17.027C11.032 16.944 12.624 16.753 14.421 15.232C14.874 15.458 15.35 15.548 16.138 15.615C16.746 15.672 17.331 15.585 17.784 15.491C18.493 15.341 18.444 14.684 18.188 14.564C16.108 13.595 16.565 13.989 16.15 13.67C17.206 12.42 18.82 10.198 19.363 7.086C19.421 6.709 19.484 6.171 19.469 5.866C19.458 5.681 19.493 5.604 19.681 5.556C20.199 5.412 20.691 5.172 21.125 4.806C22.366 3.824 22.758 2.554 22.708 1.1C22.7 0.878 22.649 0.654 22.403 0.567ZM11.175 14.451C9.159 12.726 8.182 12.088 7.778 12.067C7.401 12.047 7.469 12.505 7.552 12.807C7.639 13.103 7.752 13.313 7.91 13.581C8.02 13.758 8.095 14.01 7.801 14.16C7.152 14.487 6.023 13.806 5.97 13.772C4.657 12.85 3.559 11.766 2.785 10.369C2.037 9.025 1.603 7.583 1.532 6.044C1.513 5.672 1.622 5.541 1.992 5.473C2.479 5.383 2.981 5.364 3.468 5.436C5.525 5.736 7.276 6.675 8.744 8.323C9.582 9.299 10.216 10.425 10.869 11.496C11.563 12.592 12.31 13.603 13.262 14.414C13.598 14.696 13.866 14.91 14.123 15.068C13.349 15.154 12.058 15.167 11.175 14.452L11.175 14.451ZM12.141 8.26C12.141 8.095 12.273 7.963 12.439 7.963C12.476 7.963 12.511 7.971 12.541 7.982C12.582 7.997 12.62 8.019 12.65 8.053C12.704 8.106 12.733 8.181 12.733 8.26C12.733 8.425 12.601 8.556 12.435 8.556C12.27 8.556 12.141 8.425 12.141 8.26ZM15.142 9.799C14.949 9.878 14.757 9.945 14.572 9.953C14.284 9.968 13.972 9.851 13.802 9.709C13.537 9.487 13.348 9.363 13.27 8.977C13.236 8.812 13.255 8.556 13.284 8.41C13.352 8.094 13.277 7.892 13.055 7.708C12.873 7.558 12.643 7.516 12.39 7.516C12.296 7.516 12.209 7.475 12.145 7.441C12.039 7.389 11.952 7.257 12.035 7.096C12.062 7.043 12.19 6.916 12.22 6.893C12.563 6.698 12.96 6.762 13.326 6.908C13.665 7.047 13.922 7.302 14.292 7.663C14.669 8.098 14.738 8.218 14.953 8.545C15.123 8.801 15.277 9.063 15.383 9.364C15.447 9.551 15.364 9.705 15.142 9.799Z' +const HERO_SWIM_DOWN_PATH = + 'M23.271 2.216C23.039 2.071 22.91 2.287 22.755 2.388C22.703 2.42 22.656 2.464 22.61 2.505C22.214 2.848 21.771 3.054 21.225 2.956C20.412 2.784 19.68 2.919 19.005 3.435C18.92 2.663 18.493 2.157 17.808 1.798C17.446 1.621 17.08 1.449 16.83 1.111C16.656 0.872 16.611 0.612 16.524 0.354C16.469 0.198 16.414 0.039 16.223 0.009C16.017 -0.024 15.936 0.137 15.856 0.271C15.539 0.822 15.418 1.43 15.429 2.046C15.454 3.432 16.041 4.538 17.196 5.36C17.325 5.456 17.356 5.547 17.312 5.674C17.229 5.936 17.134 6.191 17.051 6.454C16.999 6.623 16.921 6.659 16.738 6.58C16.107 6.306 15.56 5.909 15.074 5.433C14.248 4.633 13.5 3.751 12.568 3.06C12.349 2.898 12.13 2.748 11.903 2.605C10.952 1.682 12.028 0.923 12.277 0.833C12.537 0.739 12.367 0.416 11.526 0.42C10.684 0.424 9.914 0.706 8.933 1.081C8.789 1.138 8.638 1.179 8.484 1.213C7.593 1.044 6.668 1.006 5.702 1.115C3.883 1.318 2.43 2.178 1.362 3.646C0.079 5.41 -0.223 7.415 0.147 9.506C0.535 11.71 1.66 13.535 3.389 14.962C5.181 16.441 7.246 17.166 9.601 17.027C11.032 16.944 12.624 16.753 14.421 15.232C14.874 15.458 15.35 15.548 16.138 15.615C16.746 15.672 17.331 15.585 17.784 15.491C18.493 15.341 18.444 14.684 18.188 14.564C16.108 13.595 16.565 13.989 16.15 13.67C17.206 12.42 18.82 10.198 19.278 7.246C19.318 6.948 19.375 6.534 19.371 6.293C19.371 6.145 19.411 6.092 19.597 6.098C20.113 6.109 20.619 6.051 21.096 5.891C22.503 5.375 23.169 4.232 23.448 2.804C23.49 2.586 23.491 2.356 23.271 2.216ZM11.175 14.49C9.159 13.005 8.182 12.567 7.778 12.621C7.401 12.673 7.469 13.087 7.552 13.354C7.639 13.619 7.752 13.797 7.91 14.024C8.02 14.175 8.095 14.406 7.801 14.609C7.152 15.063 6.023 14.63 5.97 14.609C4.657 13.941 3.559 12.965 2.785 11.569C2.037 10.225 1.603 8.783 1.532 7.244C1.513 6.872 1.622 6.741 1.992 6.673C2.479 6.583 2.981 6.564 3.468 6.636C5.525 6.936 7.276 7.843 8.744 9.163C9.582 9.888 10.216 10.783 10.869 11.679C11.563 12.659 12.31 13.617 13.262 14.415C13.598 14.696 13.866 14.91 14.123 15.068C13.349 15.155 12.058 15.177 11.175 14.491L11.175 14.49ZM12.141 8.26C12.141 8.095 12.273 7.963 12.439 7.963C12.476 7.963 12.511 7.971 12.541 7.982C12.582 7.997 12.62 8.019 12.65 8.053C12.704 8.106 12.733 8.181 12.733 8.26C12.733 8.425 12.601 8.556 12.435 8.556C12.27 8.556 12.141 8.425 12.141 8.26ZM15.142 9.799C14.949 9.878 14.757 9.945 14.572 9.953C14.284 9.968 13.972 9.851 13.802 9.709C13.537 9.487 13.348 9.363 13.27 8.977C13.236 8.812 13.255 8.556 13.284 8.41C13.352 8.094 13.277 7.892 13.055 7.708C12.873 7.558 12.643 7.516 12.39 7.516C12.296 7.516 12.209 7.475 12.145 7.441C12.039 7.389 11.952 7.257 12.035 7.096C12.062 7.043 12.19 6.916 12.22 6.893C12.563 6.698 12.96 6.762 13.326 6.908C13.665 7.047 13.922 7.302 14.292 7.663C14.669 8.098 14.738 8.218 14.953 8.545C15.123 8.801 15.277 9.063 15.383 9.364C15.447 9.551 15.364 9.705 15.142 9.799Z' + +/** + * The hero fish (34px wide), static at rest. Hovering swims the whale in + * place: a gentle head-up sway (CSS, on the hitbox hover) while the body + * itself morphs — SMIL interpolates `d` through the tail-up and tail-down + * targets on the same 1.6s period, so the tail wags and the fin flutters in + * real curve deformation. Decorative — hidden from the accessibility tree; + * reduced motion keeps the static filled logo on hover. + * @returns the fish svg element. + */ +function HeroFish() { + // Hover echo for the SMIL body morph: SMIL cannot ride CSS media queries, + // so the reduced-motion check gates the state instead of the style. + const [hovering, setHovering] = useState(false) + return ( + + ) +} + /** * Render the hero chrome (headline only; no glow, no composer, no workspace * row — the glow is the owner's {@link HeroGlow}). @@ -116,12 +176,15 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
+ {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} {renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, { - fallback: , + fallback: , })} - {t('hero.headline')} + + {t('hero.headline')} + {t('hero.preview')}
diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 91dfb4a8a3..f07152b3d2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -41,6 +41,7 @@ .headlineText { grid-row: 1; grid-column: 2; + white-space: pre; } .previewBadge { @@ -61,7 +62,8 @@ white-space: nowrap; } -/* Keep hover detection on a stationary box while the mark moves within it. */ +/* Keep hover detection on a stationary box while the mark moves within it; + hover rules target the hitbox so a slot-occupant mark swims too. */ .fishHitbox { grid-row: 1; grid-column: 1; @@ -70,29 +72,38 @@ justify-content: center; } -/* Keep the hero mark in the same primary ink as its headline. */ +/* The swim morph's curves poke slightly past the viewBox edges, so clipping + would slice the tail mid-wag. The hero mark stays in the same primary ink + as its headline. */ .fish { - color: var(--dsw-alias-label-primary); + display: block; + overflow: visible; transform-origin: 50% 60%; + color: var(--dsw-alias-label-primary); } +/* Hover swim: a slow continuous sway about the body center — the head + (left) lifts as the whale rises, then settles back with a small + counter-tilt, like treading water. Negative rotation = head up (the head + is the left half; origin 50% 60%). */ @keyframes hero-fish-swim { - 0%, 100% { - transform: translate(0, 0) rotate(0deg); + 0%, + 100% { + transform: none; } 35% { - transform: translate(-1px, -1px) rotate(-5deg); + transform: rotate(-4deg) translate(-0.4px, -0.9px); } 70% { - transform: translate(1px, 0) rotate(3deg); + transform: rotate(1.6deg) translate(0.3px, 0.2px); } } @media (hover: hover) and (prefers-reduced-motion: no-preference) { .fishHitbox:hover .fish { - animation: hero-fish-swim var(--ds-transition-duration-slow) var(--ds-ease-in-out); + animation: hero-fish-swim 1.6s ease-in-out infinite; } } diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 40f6dfda6d..d40e0d1ed1 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -311,7 +311,7 @@ describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const renderSlot = vi.fn(() => null) const view = render() - expect(view.getByText('Into the Unknown')).toBeTruthy() + expect(view.getByTestId('hero-headline').textContent).toBe('Into the Unknown') expect(view.getByText('Preview')).toBeTruthy() expect(renderSlot).toHaveBeenCalledOnce() expect(renderSlot.mock.calls[0]?.[0]).toBe('conversation.hero.brand.mark') @@ -439,7 +439,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('探索未至之境')).toBeTruthy() + expect(b.view.getByTestId('hero-headline').textContent).toBe('探索未至之境') expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -480,7 +480,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(sessionSnapshotOf({ blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('探索未至之境')).toBeNull() + expect(b.view.queryByTestId('hero-headline')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -505,7 +505,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('探索未至之境')).toBeTruthy() + expect(b.view.getByTestId('hero-headline').textContent).toBe('探索未至之境') expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -523,7 +523,7 @@ describe('ConversationRoot resident composer', () => { expect(b.wiring.snapshot.draft).toBe('kept across flip') expect(b.store.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('探索未至之境')).toBeNull() + expect(b.view.queryByTestId('hero-headline')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) diff --git a/packages/client/ui-primitives/src/FishLogo.tsx b/packages/client/ui-primitives/src/FishLogo.tsx index 130060c660..8621f17273 100644 --- a/packages/client/ui-primitives/src/FishLogo.tsx +++ b/packages/client/ui-primitives/src/FishLogo.tsx @@ -1,5 +1,11 @@ import type { IconProps } from './icons/props.ts' +/** Native viewBox of {@link FISH_LOGO_PATH} (width and height in user units). */ +export const FISH_LOGO_VIEWBOX = { width: 23.16, height: 17.04 } + +/** The fish silhouette path data, exported for consumers that compose their own svg (entrance effects, masks) around the same geometry. */ +export const FISH_LOGO_PATH = 'M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018ZM11.1749 14.4736C9.15936 12.889 8.18184 12.3675 7.77832 12.39C7.40081 12.4125 7.46881 12.8445 7.55182 13.126C7.63882 13.404 7.75182 13.5955 7.91033 13.8396C8.01983 14.0011 8.09533 14.2411 7.80083 14.4216C7.15181 14.8231 6.02327 14.2866 5.97027 14.2601C4.65673 13.4865 3.5587 12.4655 2.78467 11.069C2.03715 9.72493 1.60314 8.28289 1.53164 6.74384C1.51264 6.37233 1.62214 6.24082 1.99215 6.17332C2.47916 6.08332 2.98118 6.06432 3.46769 6.13582C5.52476 6.43633 7.27581 7.35586 8.74385 8.8129C9.58188 9.64243 10.2159 10.634 10.8689 11.6025C11.5634 12.631 12.3105 13.611 13.262 14.4146C13.598 14.6961 13.866 14.9101 14.1225 15.0681C13.349 15.1546 12.058 15.1731 11.1749 14.4746L11.1749 14.4736ZM12.141 8.25988C12.141 8.09488 12.273 7.96338 12.439 7.96338C12.4765 7.96338 12.5105 7.97088 12.541 7.98188C12.5825 7.99688 12.6205 8.01938 12.6505 8.05338C12.7035 8.10588 12.7335 8.18088 12.7335 8.25988C12.7335 8.42489 12.6015 8.55639 12.4355 8.55639C12.2695 8.55639 12.141 8.42489 12.141 8.25988ZM15.1415 9.79893C14.949 9.87793 14.7565 9.94544 14.5715 9.95294C14.2845 9.96794 13.9715 9.85143 13.8015 9.70893C13.5375 9.48742 13.3485 9.36342 13.2695 8.97691C13.2355 8.8119 13.2545 8.55639 13.2845 8.40989C13.3525 8.09438 13.277 7.89187 13.0545 7.70787C12.8735 7.55786 12.643 7.51636 12.39 7.51636C12.2955 7.51636 12.209 7.47486 12.1445 7.44136C12.039 7.38886 11.9519 7.25735 12.035 7.09585C12.0615 7.04335 12.19 6.91584 12.22 6.89334C12.5635 6.69784 12.9595 6.76184 13.326 6.90834C13.6655 7.04735 13.9225 7.30236 14.292 7.66287C14.6695 8.09838 14.7375 8.21838 14.9525 8.54539C15.1225 8.8009 15.277 9.06341 15.3831 9.36392C15.4471 9.55142 15.3641 9.70493 15.1415 9.79893Z' + /** * Render the fish logo. * @param props.size - width in px (default 24; height keeps the 23.16:17.04 ratio). @@ -10,13 +16,13 @@ export function FishLogo({ size = 24, className }: IconProps) { return ( ) } diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 8415fcf059..0aa23a3cbf 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,7 +22,7 @@ export { OnboardingSurface } from './OnboardingSurface.tsx' export { RiskConfirmation } from './RiskConfirmation.tsx' export type { RiskConfirmationProps } from './RiskConfirmation.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' -export { FishLogo } from './FishLogo.tsx' +export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export type { BrandWordmarkProps } from './BrandWordmark.tsx' export { ReferenceIcon } from './ReferenceIcon.tsx' 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 cb8f1a7ebc..f18f9330ff 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -588,10 +588,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', - occupants: [ - 'client-ui-brand-official OfficialBrandMark', - ], - replaceRisk: 'shadows-shipped-ui', + occupants: [], + replaceRisk: 'none', 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:123', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d5d75a10..bb409fe5e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1947,9 +1947,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives From ec493c2db871e07b647c6b1db148b992b5e63f2d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 28 Aug 2026 01:04:46 +0800 Subject: [PATCH 021/429] feat(subagent): unify adjacent agent delivery on steer --- ...7-adjacent-agent-steer-messaging.i18n.yaml | 6 + ...26-08-27-adjacent-agent-steer-messaging.md | 64 +++++ ...08-27-adjacent-agent-steer-messaging.zh.md | 64 +++++ ...agent-report-settlement-ordering.i18n.yaml | 4 +- ...-17-subagent-report-settlement-ordering.md | 2 + ...-subagent-report-settlement-ordering.zh.md | 2 + ...continuable-subagent-report-tool.i18n.yaml | 4 +- ...-07-30-continuable-subagent-report-tool.md | 2 + ...-30-continuable-subagent-report-tool.zh.md | 2 + ...subagent-continuation-operations.i18n.yaml | 4 +- ...-named-subagent-continuation-operations.md | 2 +- ...med-subagent-continuation-operations.zh.md | 2 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 27 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 23 +- docs/config-catalog.zh.md | 23 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 98 ++----- docs/subsystems/subagent.zh.md | 98 ++----- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 4 +- docs/tool-catalog.zh.md | 4 +- .../tests/chat-branch-tails.client.spec.tsx | 8 +- .../experimental/agent-team/src/mailbox.ts | 3 +- .../agent-team/tests/team.spec.ts | 48 ++-- .../extensions/tool-cordis/src/api-catalog.ts | 33 +-- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 11 +- packages/subagent/subagent/README.zh.md | 11 +- packages/subagent/subagent/package.json | 4 + .../subagent/subagent/src/continuation.ts | 272 +++++++++--------- packages/subagent/subagent/src/index.ts | 96 ++++--- packages/subagent/subagent/src/internal.ts | 55 ++++ .../tests/continuation-inheritance.spec.ts | 23 +- .../subagent/tests/continuation.spec.ts | 134 +++++---- .../subagent/subagent/tests/control.spec.ts | 44 +-- .../subagent/subagent/tests/service.spec.ts | 4 +- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 8 +- .../tool-subagent-control/README.zh.md | 8 +- .../tool-subagent-control/src/index.ts | 27 +- .../tests/tool-subagent-control.spec.ts | 34 ++- .../tool-subagent-report/README.i18n.yaml | 4 +- .../subagent/tool-subagent-report/README.md | 30 +- .../tool-subagent-report/README.zh.md | 30 +- .../tool-subagent-report/package.json | 3 - .../tool-subagent-report/src/index.ts | 41 +-- .../tests/tool-subagent-report.spec.ts | 81 ++---- .../fixtures/subagent-durability-failure.ts | 6 +- pnpm-lock.yaml | 4 - scripts/gen-cordis-catalog.ts | 7 +- scripts/gen-tool-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 19 +- .../sdk/bash-tool/tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 4 +- .../sdk/subagent-continuable/session.1.jsonl | 32 +-- .../sdk/subagent-continuable/session.jsonl | 20 +- .../tool-schemas.1.expected.json | 4 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 4 +- snapshots/sdk/subagent-report/session.1.jsonl | 4 +- snapshots/sdk/subagent-report/session.jsonl | 12 +- .../tool-schemas.1.expected.json | 4 +- .../sdk/text-turn/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 4 +- .../both-mode-turn/system-prompt.expected.md | 2 +- .../both-mode-turn/tool-schemas.expected.json | 2 +- .../system-prompt.expected.md | 2 +- .../code-mode-turn/system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 4 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../lsp-definition/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../ralph-loop/tool-schemas.1.expected.json | 2 +- .../ralph-loop/tool-schemas.2.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../text-turn/tool-schemas.expected.json | 2 +- .../web-fetch/tool-schemas.expected.json | 2 +- .../code-mode-round/system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- tsconfig.base.json | 1 + 94 files changed, 846 insertions(+), 829 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md create mode 100644 .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md create mode 100644 packages/subagent/subagent/src/internal.ts diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml new file mode 100644 index 0000000000..cad19135c0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.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-27-adjacent-agent-steer-messaging.md +2026-08-27-adjacent-agent-steer-messaging.md: 2d540a40847c61c55c0a0e1cc995e01cc924829a +2026-08-27-adjacent-agent-steer-messaging.zh.md: f04b560b8db15ffd9f6095214670ac5f99a392cb diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md new file mode 100644 index 0000000000..2d540a4084 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -0,0 +1,64 @@ +# Agent Note: Adjacent Agents share one Steer messaging operation + +Status: implemented + +English | [中文](2026-08-27-adjacent-agent-steer-messaging.zh.md) + +## Problem + +Continuable Agents had direction-specific public operations and message sources. A parent used `followup(parent, childId, content, { source, signal })`, while a child used `reportFrom(child, content, { delivery, signal })`. The first created a later FIFO turn and accepted caller-supplied provenance; the second selected quiet injection or next-step steering through deployment configuration and derived its recipient internally. + +Those differences described the tools that first consumed the service, not two lifecycle capabilities. Both directions deliver model-authored content across one parent/child edge, require the continuation manager to authorize the exact live Agents, and depend on the same residency and cold-resume ownership. Direction-specific sources also made equivalent messages reconstruct differently. + +[Issue #3220](https://github.com/deepseek-harness/deepseek-harness/issues/3220) requires one foundation before the model-facing tools are unified. + +## Decision + +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target on one adjacent edge: + +- parent to direct continuable child, authorized by the child's durable `SessionHeader.parentSession`; +- resident continuable child to its exact live direct parent, authorized by the child's Activation. + +Siblings, self-targets, ancestors beyond one edge, stale Agent objects, unknown targets, and one-shot children are not alternate routes. The operation has no caller-supplied source, delivery mode, offline parent mailbox, or provider dispatch. + +Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent direct child is cold-resumed through the existing continuation lifecycle before the same Steer delivery. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. + +Every direction uses one durable source: + +```ts +type SessionId = string + +interface AgentMessageSource { + readonly kind: 'agent-message' + readonly form: 'relay' + readonly senderSessionId: SessionId +} +``` + +The service derives `senderSessionId` from the authorized Agent and frames the model-visible content as `Agent sent a message:`. Attribution therefore cannot diverge from authority. The runtime-owned `subagent-settled` notice remains separate because its words are the manager's account, not content selected by an Agent. + +Human browser prompts are not model-authored Agent messages. The existing remote prompt path keeps a private Queue delivery so each human prompt remains a distinct turn. Interrupt behavior and settlement delivery are unchanged. + +The child-scoped `report` tool temporarily derives its parent id and adapts to `sendMessage()`. Its `reportDelivery` configuration is removed: accepted reports now use the same fixed Steer scheduling and `agent-message` provenance as parent-to-child content. A later change may unify the model-facing tools without changing this service decision. + +## Alternatives considered + +**Keep `followup` and add child-to-parent routing.** The name promises a later turn and inherits `Agent.followup()` semantics. It would obscure the chosen nearest-step behavior and preserve a parent-centric operation name for a direction-neutral capability. + +**Keep separate `followup` and `reportFrom` methods over one implementation.** Two public methods still permit different options, provenance, and error behavior to reappear. Tool-specific adapters belong in Consumer packages, not the Service Definition. + +**Let callers supply `MessageSource`.** The sender Agent is already the authorization credential. Accepting independent attribution allows a caller to record a different author from the one the manager authorized. + +**Keep quiet delivery as deployment policy.** A quiet model-authored message can be accepted while an idle target never reads it. Fixed Steer gives both directions one delivery meaning and preserves batching at a running target's step boundary. + +**Use `Agent.followup()` for idle targets and `Agent.steer()` for running targets.** `Agent.steer()` already defines both cases. Selecting from a pre-send status read would add a race and two inbox targets without changing the intended idle behavior. + +## Consequences + +- Service consumers have one direction-neutral model messaging operation and one provenance vocabulary. +- The continuation manager remains the sole owner of adjacency authorization, residency, cold resume, waking admission, and teardown races. +- Accepted messages may extend a running target's current turn. Several messages waiting together share next-step FIFO ordering. +- Caller cancellation owns work only until inbox acceptance; it does not retract an accepted message or dispose the target. +- Human prompts, settlement notices, QueueDock, and continuable fork enablement remain separate decisions. + +This decision supersedes the `followup` naming choice in [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md), the public `reportFrom` and configurable delivery portions of [Continuable subagent report tool](../feature/2026-07-30-continuable-subagent-report-tool.md), and the report-specific delivery choice in [Subagent reports precede their settlement notices](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md). Their provider, setup-contribution, prompt-guidance, durability, and settlement-ordering rationale remains applicable where not replaced here. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md new file mode 100644 index 0000000000..f04b560b8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -0,0 +1,64 @@ +# Agent Note:相邻 Agent 共享一个 Steer 消息操作 + +状态:已实现 + +[English](2026-08-27-adjacent-agent-steer-messaging.md) | 中文 + +## 问题 + +可继续 Agent 曾使用按方向划分的公开操作与消息来源。parent 使用 `followup(parent, childId, content, { source, signal })`,child 使用 `reportFrom(child, content, { delivery, signal })`。前者创建后续 FIFO 轮次并接受调用方提供的来源信息;后者通过部署配置选择静默注入或 next-step steering,并在内部推导接收方。 + +这些差异描述的是最初消费服务的工具,而不是两种生命周期能力。两个方向都跨一条 parent/child 边投递模型编写的内容,都要求继续执行管理器授权确切在线 Agent,也都依赖相同的驻留与冷恢复所有权。按方向划分来源还会让等价消息以不同方式重建。 + +[Issue #3220](https://github.com/deepseek-harness/deepseek-harness/issues/3220) 要求先统一这层基础,再统一面向模型的工具。 + +## 决策 + +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender,以及位于一条相邻边上的目标: + +- parent 到直接可继续 child,由 child 持久化的 `SessionHeader.parentSession` 授权; +- 驻留的可继续 child 到其确切在线直接 parent,由 child 的 Activation 授权。 + +sibling、self-target、相隔多于一条边的 ancestor、陈旧 Agent 对象、未知目标与一次性 child 都不是备用路由。该操作不接受调用方提供的 source、投递模式、离线 parent mailbox 或提供方分发。 + +每条被接受的消息都使用 `Agent.steer()`。运行中的目标在最近的 step 边界接收消息;空闲目标启动一个轮次。不存在 Activation 的直接 child 会先经既有继续执行生命周期完成冷恢复,再接受相同的 Steer 投递。管理器保留唤醒发送记账,避免受继续执行管理的目标在同步 inbox 插入与 driver 准入之间结算。 + +两个方向都使用同一个持久化来源: + +```ts +type SessionId = string + +interface AgentMessageSource { + readonly kind: 'agent-message' + readonly form: 'relay' + readonly senderSessionId: SessionId +} +``` + +服务从已授权 Agent 推导 `senderSessionId`,并把模型可见内容设为 `Agent sent a message:` 前缀。来源信息因此无法偏离权限。由 runtime 生成的 `subagent-settled` 通知保持独立,因为其中的文字是管理器的记账,而不是 Agent 选择的内容。 + +浏览器中的人类提示不是由模型编写的 Agent 消息。既有远程提示路径保留私有 Queue 投递,使每条人类提示继续形成独立轮次。中断行为与结算投递不变。 + +child 作用域的 `report` 工具暂时推导 parent id,并适配到 `sendMessage()`。其 `reportDelivery` 配置被移除:被接受的报告现在与 parent 到 child 的内容使用相同的固定 Steer 调度与 `agent-message` 来源。后续变更可以统一面向模型的工具,而无需改变该服务决策。 + +## 考虑过的替代方案 + +**保留 `followup` 并添加 child 到 parent 路由。** 该名称承诺后续轮次,并继承 `Agent.followup()` 语义。它会掩盖已选择的最近 step 行为,也会为方向中立的能力保留以 parent 为中心的操作名称。 + +**在同一实现上保留独立的 `followup` 与 `reportFrom` 方法。** 两个公开方法仍允许不同的 options、来源信息与错误行为重新出现。工具专属适配器应归 Consumer 包所有,而不是归 Service Definition 所有。 + +**允许调用方提供 `MessageSource`。** sender Agent 已是权限凭据。接受独立来源信息会允许调用方记录一个不同于管理器已授权 Agent 的作者。 + +**保留静默投递作为部署策略。** 静默的模型编写消息可能已被接受,但空闲目标永远不会读取。固定 Steer 为两个方向提供同一种投递含义,并保留运行中目标 step 边界的批处理。 + +**对空闲目标使用 `Agent.followup()`,对运行中目标使用 `Agent.steer()`。** `Agent.steer()` 已定义这两种情况。根据发送前读取的状态选择方法会增加竞态与两个 inbox 目标,却不会改变预期的空闲行为。 + +## 后果 + +- 服务 Consumer 只有一个方向中立的模型消息操作与一种来源词汇。 +- 继续执行管理器仍是相邻关系授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 +- 被接受的消息可能延长运行中目标的当前轮次。多条共同等待的消息共享 next-step FIFO 顺序。 +- 调用方取消只在 inbox 接受前掌管工作;它不会撤回已接受消息,也不会 dispose 目标。 +- 人类提示、结算通知、QueueDock 与启用可继续 fork 仍是独立决策。 + +本决策取代[按意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md)中的 `followup` 命名选择、[可继续 subagent report 工具](../feature/2026-07-30-continuable-subagent-report-tool.zh.md)中的公开 `reportFrom` 与可配置投递部分,以及[Subagent 报告先于其结算通知](../bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md)中的 report 专属投递选择。它们关于提供方、设置贡献、提示词指导、持久性与结算顺序的理由,在未被本记录取代之处仍然适用。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.i18n.yaml index a43c34d0d1..c5ea2bb1db 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.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-17-subagent-report-settlement-ordering.md -2026-08-17-subagent-report-settlement-ordering.md: 30dfab5e96a7cea2ef6d4f03f480d17a86c5e775 -2026-08-17-subagent-report-settlement-ordering.zh.md: 658eb18e3a8cb40734136af32c6c62faef066a6e +2026-08-17-subagent-report-settlement-ordering.md: 8d8ef6ac1708eb66d7e9b80235cc563f2ab38989 +2026-08-17-subagent-report-settlement-ordering.zh.md: 6666baff72c3ee4ee6079bb73318f944b646d3bb diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md index 30dfab5e96..8d8ef6ac17 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-17-subagent-report-settlement-ordering.zh.md) +[Adjacent Agents share one Steer messaging operation](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) makes Steer mandatory for every model-authored adjacent-Agent message and removes quiet delivery. This record still owns why an Agent message accepted before a settlement notice must precede that notice in the shared next-step FIFO. + ## Problem A continuable child can explicitly report selected content and later produce an unconditional manager-authored settlement notice. Report delivery used `Agent.followup()` and entered the parent's `next-turn` queue, while settlement delivery to a running parent used `Agent.steer()` and entered `next-step`. The first step of a turn claims the complete `next-step` batch before one `next-turn` message, so the later settlement notice could reach the model before the earlier report. The assembled report scenario required `reportDelivery: quiet` to avoid that nondeterministic interleaving. [Issue #2600](https://github.com/deepseek-harness/deepseek-harness/issues/2600) records the defect. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md index 658eb18e3a..6666baff72 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-17-subagent-report-settlement-ordering.md) | 中文 +[相邻 Agent 共享一个 Steer 消息操作](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)要求所有模型编写的相邻 Agent 消息使用 Steer,并移除静默投递。本记录仍负责解释:先于结算通知被接受的 Agent 消息必须在共享 next-step FIFO 中先于该通知。 + ## 问题 可继续 child 可以显式上报选中内容,之后还会产生一条由管理器撰写且无条件投递的结算通知。报告投递曾使用 `Agent.followup()` 并进入 parent 的 `next-turn` 队列,而面向运行中 parent 的结算投递使用 `Agent.steer()` 并进入 `next-step`。一个轮次的第一个 step 会先领取完整 `next-step` 批次,再领取一条 `next-turn` 消息,因此较晚的结算通知可能先于较早的报告到达模型。整体组装的报告场景必须使用 `reportDelivery: quiet`,才能避开这种不确定交错。[Issue #2600](https://github.com/deepseek-harness/deepseek-harness/issues/2600)记录了该缺陷。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index a0028ab5c1..ab157c0001 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-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-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: 07d17f18f318a86070d9b8612512fa3c2a3815e2 -2026-07-30-continuable-subagent-report-tool.zh.md: 9cbf76a9f3f50d9b1a84b573621cd6fc2a1611c4 +2026-07-30-continuable-subagent-report-tool.md: deb124c8d8e71d4dd0574e877580b8ad73b64f80 +2026-07-30-continuable-subagent-report-tool.zh.md: cc0d2e59454be0a070aca383993bb235ac9038aa diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 07d17f18f3..deb124c8d8 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-30-continuable-subagent-report-tool.zh.md) +The child-scoped setup and prompt-guidance decisions remain current while the adapter exists. [Adjacent Agents share one Steer messaging operation](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) supersedes this record's public `reportFrom`, direction-specific provenance, and configurable delivery decisions. + ## Problem Continuable in-process subagents can receive later parent messages, retain descendants, settle, and cold-resume, but the base lifecycle gives them no way to send selected content back to their direct parent. Their complete output already remains reconstructable from the durable child Session, so the missing capability is explicit delivery rather than result storage. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index 9cbf76a9f3..cc0d2e5945 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-30-continuable-subagent-report-tool.md) | 中文 +只要该适配器仍然存在,child 作用域设置与提示词指导决策就仍然有效。[相邻 Agent 共享一个 Steer 消息操作](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)取代了本记录的公开 `reportFrom`、按方向划分来源与可配置投递决策。 + ## 问题 可继续的进程内 subagent 能够接收 parent 后续发来的消息、保留后代、结算并冷恢复,但基础生命周期无法让它们将选中内容发送给直接 parent。child 的完整输出已可从持久化会话中重建,因此缺失的能力是显式投递,而非结果存储。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index 7807087bf1..7eeff8defe 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 72fb042978cdb8faa238d7483c17eb7c7fce80bd -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 8734c439fd4f3baf384d6fa1fc2ae197e2ed5b64 +2026-07-27-intent-named-subagent-continuation-operations.md: b25ca03a7427ac7b4b7de87f52e51a8ef970dfbd +2026-07-27-intent-named-subagent-continuation-operations.zh.md: a9c737a8a216c3eaa824080383baaacf8ba43c15 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 72fb042978..b25ca03a74 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The current activation-based realization is owned by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). It retains the `followup` operation this record names, returns the accepted `MessageId`, uses the bare `Agent` parameter as exact live-direct-parent authority, and limits provider participation in continuable children to `prepareContinuable`. +The provider-request and session-flush decisions remain current. [Adjacent Agents share one Steer messaging operation](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) supersedes this record's `followup` naming and options: the public operation is now `sendMessage(sender, targetId, content, { signal })` for either adjacent direction. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 8734c439fd..a9c737a8a2 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -当前基于 Activation 的实现由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.zh.md)负责。它保留本记录命名的 `followup` 操作,返回已接受的 `MessageId`,使用裸 `Agent` 参数作为确切的在线直属父级权限,并将提供方对可继续 child 的参与限制为 `prepareContinuable`。 +提供方请求与会话 flush 决策仍然有效。[相邻 Agent 共享一个 Steer 消息操作](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)取代了本记录的 `followup` 命名与 options:公开操作现在是可用于任一相邻方向的 `sendMessage(sender, targetId, content, { signal })`。 ## 问题 diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 6f2ca01325..d385ce4c22 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -20,6 +20,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SubagentPromptRequestId } from '@deepseek-ai/dsh-subagent' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -220,12 +221,13 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co // Keep the continuable Activation resident after this first abort. The // direct setup queue does not change the parent-offline UI contract: its // input and Send remain disabled throughout the exercised browser path. - await scaffold.ctx.subagents.followup( - parent, - childId, - [{ type: 'text', text: REARM }], - { source: { kind: 'user' }, signal: new AbortController().signal }, - ) + await scaffold.ctx.subagents.prompt({ + requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId, + parentSessionId: parent.id, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: REARM }], + }, new AbortController().signal) const aborted = waitForAbortedTurn(scaffold, childId) const interruptResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/subagents/interruptByParent') @@ -239,12 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co // Wake the parked setup message only after cancellation converges. A // second hang keeps the parent-available case independent from this stop. - await scaffold.ctx.subagents.followup( - parent, - childId, - [{ type: 'text', text: REARM_WAKE }], - { source: { kind: 'user' }, signal: new AbortController().signal }, - ) + await scaffold.ctx.subagents.prompt({ + requestId: 'interrupt-ui-rearm-wake' as SubagentPromptRequestId, + parentSessionId: parent.id, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: REARM_WAKE }], + }, new AbortController().signal) await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open') expect(scaffold.ctx.agents.get(childId)?.status).toBe('running') } finally { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 638000d0ef..49f9b572d7 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: 4331c0a5153f32f0e6af5b6ec6fd182ee9b335b4 -config-catalog.zh.md: 54f6ddde10053d422af2c5ebfd88a367597adc89 +config-catalog.md: a9cc4be949441c4ca546684f2d109de9cfff787d +config-catalog.zh.md: 7b9d63fa189bdaecfff21cbebd2811b47a22a2d3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4331c0a515..a9cc4be949 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3001,28 +3001,6 @@ Depends on: [`AgentOptions`](subsystems/core.md) Source: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) - - -## `@deepseek-ai/dsh-tool-subagent-report` - -Requires: `subagents` · `tools` · `systemPrompt` - -```ts config-catalog -/** Config: how accepted reports are scheduled on the parent. */ -export interface Config { - /** - * Parent scheduling (default `next-step`). `next-step` wakes the parent and - * enters at its nearest step boundary; `quiet` adds the same context without - * waking, so a parked parent waits for another waking input. - */ - reportDelivery?: SubagentReportDelivery -} -``` - -Depends on: [`SubagentReportDelivery`](subsystems/subagent.md) - -Source: [`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/subagent/tool-subagent-report/src/index.ts) - ## `@deepseek-ai/dsh-tool-terminal` @@ -3470,6 +3448,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-subagent-report` — requires `subagents` · `tools` · `systemPrompt` ([`packages/subagent/tool-subagent-report/src/index.ts`](../packages/subagent/tool-subagent-report/src/index.ts)) - `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts)) - `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 54f6ddde10..7b9d63fa18 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3003,28 +3003,6 @@ export interface Config { 来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) - - -## `@deepseek-ai/dsh-tool-subagent-report` - -需要:`subagents` · `tools` · `systemPrompt` - -```ts config-catalog -/** Config: how accepted reports are scheduled on the parent. */ -export interface Config { - /** - * Parent scheduling (default `next-step`). `next-step` wakes the parent and - * enters at its nearest step boundary; `quiet` adds the same context without - * waking, so a parked parent waits for another waking input. - */ - reportDelivery?: SubagentReportDelivery -} -``` - -依赖:[`SubagentReportDelivery`](subsystems/subagent.zh.md) - -来源:[`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/subagent/tool-subagent-report/src/index.ts) - ## `@deepseek-ai/dsh-tool-terminal` @@ -3472,6 +3450,7 @@ export interface Config { - `@deepseek-ai/dsh-tool-call-timeout-policy` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-cordis` — 需要 `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect`([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — 需要 `tools` · `subagents`([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-subagent-report` — 需要 `subagents` · `tools` · `systemPrompt`([`packages/subagent/tool-subagent-report/src/index.ts`](../packages/subagent/tool-subagent-report/src/index.ts)) - `@deepseek-ai/dsh-user-questions`([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts)) - `@deepseek-ai/dsh-webhook` — 需要 `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry`([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts)) - `@deepseek-ai/dsh-workspace` — 需要 `storageDomain` · `sessionPersistence`([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e5cf853414..8aaadce4d1 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: e849cb84265c0781e4a8680d0bb247e9955b5c2e -event-producer-consumer.zh.md: b5835c56b26f3a75fd792d3a71c3ab2dc688ea42 +event-producer-consumer.md: 529c8b0f95f9ae473e18413478f4fbe9c93a3dbc +event-producer-consumer.zh.md: 76d2b4aef326d3ea9fb4026c6155c2009a9b4c38 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e849cb8426..529c8b0f95 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -52,10 +52,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:175`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b5835c56b2..76d2b4aef3 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -54,10 +54,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:175`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 9e82c64ceb..33664f85b2 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 9206672b89ac31e30bee176f536b3057eb37f3ee -subagent.zh.md: 30e4e09a145a4b3d51044d85c602ce81fd44c3b3 +subagent.md: adfd51a37a25dbbf999211fd5f91b8a8ec7f003e +subagent.zh.md: bd85a1faf0594e0122384d93811f8776ea62ce9b diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 9206672b89..adfd51a37a 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -133,21 +133,21 @@ persisted Session `SubagentRuntime.startContinuable()` reserves the stable child id, snapshots the versioned `subagent/descriptor` payload, asks the named provider for its detached `ContinuableCreateSpec`, creates the child Agent through a private activation-owner scope, establishes any continuable-parent ownership, and submits the initial prompt. It resolves with `{ childId, messageId }` when inbox acceptance yields the message id — without waiting for the turn to start or for the message to enter the Session log. Every failure before that acceptance rejects with neither id, disposing any created handle and rolling back the Activation and parent ownership. -`SubagentRuntime.followup()` is the sole continuation-message operation, and routing depends only on Activation residency: +`SubagentRuntime.sendMessage()` is the sole model-authored message operation. It accepts the exact live sender plus a target id, permits only a direct parent or direct continuable child, derives sender attribution itself, and routes a direct-child target by Activation residency: -| Activation state | `followup` | +| Target Activation state | `sendMessage` | |---|---| -| `running` | enqueue in the same Activation | -| `waiting` | wake the same Activation | -| no Activation | cold-resume a new Activation | +| `running` | steer the nearest step in the same Activation | +| `waiting` | wake and steer the same Activation | +| no Activation | cold-resume a new Activation, then steer it | `running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the [`AgentHandle`](core.md#creation-and-ownership) and removes the Activation. The manager derives these internal conditions from Agent quiescence and the owned-child set rather than maintaining a second execution state machine. -The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so accepted messages have one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route. +The Agent inbox is the only queue. Every Agent message uses `Agent.steer()`: an idle target starts a turn, while a running target claims it at the nearest step boundary. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route. -Follow-up authority comes from an exact live Agent tool context. The authenticated Agent must be the durable child's direct parent recorded in `SessionHeader.parentSession`. `MessageSource` and `senderSessionId` record who supplied an admitted message but grant no authority; the optional model-facing tool uses `CoordinatorMessageSource`. +Authority comes from the exact live sender. Parent-to-child delivery requires the target's `SessionHeader.parentSession` to name the sender; child-to-parent delivery requires the sender's resident Activation to name the target. Siblings, ancestors beyond one edge, self-targets, stale Agent objects, and one-shot children are rejected. Each accepted message is framed as `Agent sent a message:` and records `AgentMessageSource`; provenance records the sender but grants no authority. -For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no steering operation. +For `startContinuable()` and `sendMessage()`, the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child. Human browser prompts remain a separate private Queue adapter and therefore still produce distinct FIFO turns. `SubagentRuntime.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. @@ -167,21 +167,19 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; Final settlement awaits `ctx.sessions.flush(session)` but ignores its participation boolean because an arbitrary listener cannot prove that a persistence backend stored the state. Rejection is logged without failing the Activation, and the manager still disposes the handle and releases ownership; the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv -/** Attribution for a model coordinator's follow-up to one of its children. */ -interface CoordinatorMessageSource { - readonly kind: 'coordinator' +/** Durable attribution for one model-authored message between adjacent Agents. */ +interface AgentMessageSource { + readonly kind: 'agent-message' /** A message another agent addressed to this one (`relay` context form). */ readonly form: 'relay' - /** Session id of the agent whose tool call produced the follow-up. */ + /** Session id of the Agent whose tool call produced the message. */ readonly senderSessionId: SessionId } ``` ```ts type-equiv -/** Options for following up with one continuable child. */ -interface SubagentFollowupOptions { - /** Durable attribution retained on the delivered message; it grants no authority. */ - readonly source: MessageSource +/** Options for one model-authored message between adjacent Agents. */ +interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } @@ -199,23 +197,7 @@ interface ContinuableStart { An optional continuable-child setup contribution can install scope-local capabilities after base child composition and before Activation publication. The registry is ordered and transactional: a failed or revoked setup rolls back the unpublished Activation, child-scope disposal releases every installation, new registrations affect the next Activation, and registration removal revokes every resident installation immediately. -`SubagentRuntime.reportFrom()` uses that extension point without adding a second queue or a result-bearing child wrapper. The exact live child Agent authorizes the call; callers cannot name a recipient. The manager derives the only recipient from the child's durable `parentSession`, requires that parent Agent to be live, frames the selected content as one `subagent-report` user message, and returns the message's stable `MessageId`. Quiet delivery uses `Agent.inject()` and does not wake the parent; next-step delivery uses `Agent.steer()`, waking an idle parent or joining a running parent's nearest step boundary. Neither mode concludes the child's turn, and no final answer reports implicitly. - -```ts type-equiv -/** Durable attribution for a continuable child's explicit parent report. */ -interface SubagentReportMessageSource { - readonly kind: 'subagent-report' - /** A message another agent addressed to this one (`relay` context form). */ - readonly form: 'relay' - /** Session id of the reporting child. */ - readonly senderSessionId: SessionId -} -``` - -```ts type-equiv -/** Deployment scheduling policy for accepted child reports. */ -type SubagentReportDelivery = 'quiet' | 'next-step' -``` +The optional child-scoped `report` tool uses that extension point as a compatibility adapter over `sendMessage()`. It derives the target from the child's durable `parentSession`; accepted content therefore uses the same fixed Steer scheduling, framing, provenance, adjacency checks, and stable `MessageId` as parent-to-child messages. Reporting does not conclude the child's turn, and no final answer reports implicitly. Reporting is the child's own choice, so the manager keeps a separate account of its own: when a resident Activation settles, it delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking-admission accounting as a report. A parent whose own lineage is already tearing down receives it without a wake, because waking a quiescent Agent starts a turn rather than queueing work. Its provenance is a distinct kind so a transcript never presents a runtime account as something the child wrote. @@ -223,7 +205,7 @@ Reporting is the child's own choice, so the manager keeps a separate account of /** * Durable attribution for the runtime's own account of a continuable child * settling. Deliberately a different kind from - * {@link SubagentReportMessageSource}: a report is content the child chose, + * {@link AgentMessageSource}: an Agent message is content the sender chose, * while this message is the manager stating what became of the child, and a * transcript that merged them would credit the child with words it never wrote. */ @@ -238,16 +220,6 @@ interface SubagentSettledMessageSource { } ``` -```ts type-equiv -/** Options for one continuable child's report to its direct parent. */ -interface SubagentReportOptions { - /** Already-resolved parent scheduling policy. */ - readonly delivery: SubagentReportDelivery - /** Caller cancellation, owning authorization and admission until acceptance. */ - readonly signal: AbortSignal -} -``` - The provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its returned spec carries only detached provider-specific creation inputs — the optional parent-history seed — and no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. Cold resume does not dispatch through a provider at all: the manager folds the generic descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, and submits the waiting turn. ```ts type-equiv @@ -532,21 +504,20 @@ Named provider registry with one-shot runs, durable discovery, and continuable-c async startContinuable(spec: ContinuableStartSpec): Promise /** - * Deliver one later message to a continuable child as its next FIFO turn. A - * resident child's Agent inbox accepts it directly (waking a `waiting` - * Activation), while an absent one is cold-resumed from its persisted - * Session. The Agent inbox is the only queue, so every accepted message has - * one observable order. - * @param parent - the exact live direct parent authorizing this delivery. - * @param childId - durable child session id. - * @param content - user-role content to deliver. - * @param options - the message source fields and caller cancellation, which stops the - * operation only before inbox acceptance. + * Steer one model-authored message to the sender's direct parent or direct + * continuable child. A running target admits it at the nearest step boundary; + * an idle target starts a turn, and an absent direct child cold-resumes from + * persistence. The service derives durable sender attribution from the exact + * live sender. Caller cancellation stops only pre-acceptance work. + * @param sender - exact live Agent authorizing and originating the message. + * @param targetId - durable direct-parent or direct-child session id. + * @param content - model-authored content to deliver. + * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, parent authority is - * rejected, or the message was not admitted. + * @throws when continuation services are unavailable, adjacency is rejected, + * or the message was not admitted. */ -async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** * Interrupt one live continuable child's current turn under a human parent @@ -565,19 +536,6 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void -/** - * Deliver selected content from one live continuable child to its durable - * direct parent. The child is the authority credential; callers cannot name a - * recipient. Reporting does not conclude the child's turn or Activation. - * @param child - exact live reporting child. - * @param content - selected model-facing content. - * @param options - parent scheduling and pre-acceptance cancellation. - * @returns the stable identity of the parent-accepted message. - * @throws when continuation services are unavailable, sender authorization - * fails, or the direct parent is not live. - */ -async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise - /** * Compose one deployment capability into every continuable child's * unpublished creation context on fresh creation and cold resume. Grants wait diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 30e4e09a14..bd85a1faf0 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -133,21 +133,21 @@ persisted Session `SubagentRuntime.startContinuable()` 会预留稳定的子 agent id,对版本化的 `subagent/descriptor` payload 建立快照,向指定提供方索取其分离的 `ContinuableCreateSpec`,通过私有的 activation-owner 作用域创建子 Agent,建立任何可继续父级的所有权,并提交初始提示词。当收件箱(inbox)准入产出消息 id 时,它以 `{ childId, messageId }` resolve——无需等待轮次开始,也无需等待消息进入会话日志。在该准入之前的任何失败都会以两个 id 都不返回的方式 reject,并 dispose(资源释放)任何已创建的 handle,回滚 Activation 与父级所有权。 -`SubagentRuntime.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态: +`SubagentRuntime.sendMessage()` 是唯一由模型编写消息的操作。它接收确切在线 sender 与目标 id,只允许直接 parent 或直接可继续 child,自行推导 sender 来源信息,并根据目标 child 的 Activation 驻留状态路由: -| Activation 状态 | `followup` | +| 目标 Activation 状态 | `sendMessage` | |---|---| -| `running` | 在同一 Activation 中入队 | -| `waiting` | 唤醒同一 Activation | -| 无 Activation | 冷恢复一个新的 Activation | +| `running` | 在同一 Activation 中 steer 最近的 step | +| `waiting` | 唤醒并 steer 同一 Activation | +| 无 Activation | 冷恢复新的 Activation,然后 steer | `running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已完全停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已完全停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose [`AgentHandle`](core.zh.md#creation-and-ownership) 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些内部条件,而非维护第二套执行状态机。 -Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此已接受的消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。 +Agent 收件箱是唯一队列。每条 Agent 消息都使用 `Agent.steer()`:空闲目标会启动一个轮次,运行中目标则在最近的 step 边界领取消息。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。 -后续操作的权限来自确切的在线 Agent 工具上下文。已认证的 Agent 必须是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级。`MessageSource` 与 `senderSessionId` 记录谁提供了已准入的消息,但不授予任何权限;可选的面向模型工具使用 `CoordinatorMessageSource`。 +权限来自确切在线 sender。parent 到 child 的投递要求目标的 `SessionHeader.parentSession` 指向 sender;child 到 parent 的投递要求 sender 的驻留 Activation 指向目标。sibling、相隔多于一条边的 ancestor、self-target、陈旧 Agent 对象与一次性 child 都会被拒绝。每条已接受消息都以 `Agent sent a message:` 作为前缀,并记录 `AgentMessageSource`;来源信息记录 sender,但不授予权限。 -对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 steering(中途引导)操作。 +对于 `startContinuable()` 与 `sendMessage()`,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。浏览器中的人类提示仍由私有 Queue 适配器处理,因此继续产生独立 FIFO 轮次。 `SubagentRuntime.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待完全停稳即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;陈旧的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 @@ -167,21 +167,19 @@ type SubagentInterruptAuthority = 最终结算会等待 `ctx.sessions.flush(session)`,但会忽略其参与布尔值,因为任意 listener 都无法证明某个持久化后端已存储该状态。rejection 会被记录,但不会使 Activation 失败;管理器仍会 dispose 该 handle 并释放所有权,此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv -/** Attribution for a model coordinator's follow-up to one of its children. */ -interface CoordinatorMessageSource { - readonly kind: 'coordinator' +/** Durable attribution for one model-authored message between adjacent Agents. */ +interface AgentMessageSource { + readonly kind: 'agent-message' /** A message another agent addressed to this one (`relay` context form). */ readonly form: 'relay' - /** Session id of the agent whose tool call produced the follow-up. */ + /** Session id of the Agent whose tool call produced the message. */ readonly senderSessionId: SessionId } ``` ```ts type-equiv -/** Options for following up with one continuable child. */ -interface SubagentFollowupOptions { - /** Durable attribution retained on the delivered message; it grants no authority. */ - readonly source: MessageSource +/** Options for one model-authored message between adjacent Agents. */ +interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } @@ -199,23 +197,7 @@ interface ContinuableStart { 可选的可继续 child 设置贡献可以在 child 基础组合完成后、Activation 发布前安装限定在作用域内的能力。该注册表按顺序执行且具有事务性:设置失败或被撤销时会回滚未发布的 Activation;child 作用域 dispose 时会释放所有安装;新注册项在下一个 Activation 生效;移除注册项时则会立即撤销每个驻留中的安装。 -`SubagentRuntime.reportFrom()` 通过该扩展点实现报告,无需新增第二条队列或承载结果的 child 包装层。调用由确切的在线 child Agent 授权,调用方不能指定接收方。管理器从 child 的持久化 `parentSession` 中推导唯一接收方,要求该 parent Agent 必须在线,将选中内容封装为一条 `subagent-report` 用户消息,并返回该消息的稳定 `MessageId`。静默投递使用 `Agent.inject()`,不会唤醒 parent;next-step 投递使用 `Agent.steer()`,会唤醒空闲 parent,或加入运行中 parent 最近的 step 边界。两种模式都不会结束 child 轮次,最终回答也不会隐式报告。 - -```ts type-equiv -/** Durable attribution for a continuable child's explicit parent report. */ -interface SubagentReportMessageSource { - readonly kind: 'subagent-report' - /** A message another agent addressed to this one (`relay` context form). */ - readonly form: 'relay' - /** Session id of the reporting child. */ - readonly senderSessionId: SessionId -} -``` - -```ts type-equiv -/** Deployment scheduling policy for accepted child reports. */ -type SubagentReportDelivery = 'quiet' | 'next-step' -``` +可选的 child 作用域 `report` 工具通过该扩展点实现,并作为 `sendMessage()` 的兼容适配器。它从 child 的持久化 `parentSession` 推导目标;已接受内容因此与 parent 到 child 的消息使用相同的固定 Steer 调度、前缀、来源信息、相邻关系校验与稳定 `MessageId`。报告不会结束 child 轮次,最终回答也不会隐式报告。 上报是 child 自己的选择,因此管理器还保有一份属于自己的记账:当驻留 Activation 结算时,它会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与上报相同的唤醒准入记账到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个静息 Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。 @@ -223,7 +205,7 @@ type SubagentReportDelivery = 'quiet' | 'next-step' /** * Durable attribution for the runtime's own account of a continuable child * settling. Deliberately a different kind from - * {@link SubagentReportMessageSource}: a report is content the child chose, + * {@link AgentMessageSource}: an Agent message is content the sender chose, * while this message is the manager stating what became of the child, and a * transcript that merged them would credit the child with words it never wrote. */ @@ -238,16 +220,6 @@ interface SubagentSettledMessageSource { } ``` -```ts type-equiv -/** Options for one continuable child's report to its direct parent. */ -interface SubagentReportOptions { - /** Already-resolved parent scheduling policy. */ - readonly delivery: SubagentReportDelivery - /** Caller cancellation, owning authorization and admission until acceptance. */ - readonly signal: AbortSignal -} -``` - 提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——即可选的父级历史种子——不含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。 ```ts type-equiv @@ -536,21 +508,20 @@ Named provider registry with one-shot runs, durable discovery, and continuable-c async startContinuable(spec: ContinuableStartSpec): Promise /** - * Deliver one later message to a continuable child as its next FIFO turn. A - * resident child's Agent inbox accepts it directly (waking a `waiting` - * Activation), while an absent one is cold-resumed from its persisted - * Session. The Agent inbox is the only queue, so every accepted message has - * one observable order. - * @param parent - the exact live direct parent authorizing this delivery. - * @param childId - durable child session id. - * @param content - user-role content to deliver. - * @param options - the message source fields and caller cancellation, which stops the - * operation only before inbox acceptance. + * Steer one model-authored message to the sender's direct parent or direct + * continuable child. A running target admits it at the nearest step boundary; + * an idle target starts a turn, and an absent direct child cold-resumes from + * persistence. The service derives durable sender attribution from the exact + * live sender. Caller cancellation stops only pre-acceptance work. + * @param sender - exact live Agent authorizing and originating the message. + * @param targetId - durable direct-parent or direct-child session id. + * @param content - model-authored content to deliver. + * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, parent authority is - * rejected, or the message was not admitted. + * @throws when continuation services are unavailable, adjacency is rejected, + * or the message was not admitted. */ -async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** * Interrupt one live continuable child's current turn under a human parent @@ -569,19 +540,6 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void -/** - * Deliver selected content from one live continuable child to its durable - * direct parent. The child is the authority credential; callers cannot name a - * recipient. Reporting does not conclude the child's turn or Activation. - * @param child - exact live reporting child. - * @param content - selected model-facing content. - * @param options - parent scheduling and pre-acceptance cancellation. - * @returns the stable identity of the parent-accepted message. - * @throws when continuation services are unavailable, sender authorization - * fails, or the direct parent is not live. - */ -async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise - /** * Compose one deployment capability into every continuable child's * unpublished creation context on fresh creation and cold resume. Grants wait diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 569d755993..12fbbf3ac0 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: 16142c2f7d98cf1037b034d2836c742b62f26594 -tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488 +tool-catalog.md: d04c8bd9c54e8110f36c8d9967c420ad16397d5a +tool-catalog.zh.md: 70bcea7e10200174ef42fb199ac81fa95263b257 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 16142c2f7d..d04c8bd9c5 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1640,7 +1640,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag ### `send_message` -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 a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered. ```json { @@ -1672,7 +1672,7 @@ The globally named control tools over continuable background subagents: provider ### `report` -Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it. +Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. If that agent is working, the report steers its nearest step; otherwise it starts a turn. A failed call may still have arrived if a later tool-result hook failed, so do not blindly repeat it. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 533caacc7a..70bcea7e10 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1646,7 +1646,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `send_message` -根据 subagent id 向后台 subagent 发送消息,继续同一段对话。该消息会成为 subagent 的下一轮次:如果它仍在工作,消息会等待当前轮次结束,因此无法改变已经开始的工作方向。此调用不会返回 subagent 的答案,只会确认消息已投递,因此请用它分派更多工作。调用失败表示消息**未**投递。 +根据 subagent id 向后台 subagent 发送消息,继续同一段对话。如果它仍在工作,消息会 steer 其最近的 step;如果它处于 idle,消息会启动一个轮次。此调用不会返回 subagent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。 ```json { @@ -1678,7 +1678,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `report` -向启动你的 agent 报告选定内容。在你结束前调用一次,给出自包含的最终结果;当进度或发现会改变该 agent 接下来的行动时,也可以更早调用。该 agent 与你共享工作区,但不会自动收到你的 transcript(文本记录)、工具输出或推理,因此完成你的工作本身并不等于交出结果。报告不会结束你的轮次或完成你的工作,且只有直接父级会收到。失败的调用仍可能已经送达,因此不要盲目重复。 +向启动你的 agent 报告选定内容。在你结束前调用一次,给出自包含的最终结果;当进度或发现会改变该 agent 接下来的行动时,也可以更早调用。该 agent 与你共享工作区,但不会自动收到你的 transcript(文本记录)、工具输出或推理,因此完成你的工作本身并不等于交出结果。报告不会结束你的轮次或完成你的工作,且只有直接 parent 会收到。如果该 agent 正在工作,报告会 steer 其最近的 step;否则会启动一个轮次。如果后续工具结果 hook 失败,失败的调用仍可能已经送达,因此不要盲目重复。 ```json { diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 985a452fd6..b392c1907c 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -701,7 +701,7 @@ describe('MessageItem arms', () => { // reach it, and the row marker must not claim a form that did not render. const cases = [ { form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' }, - { form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' }, + { form: 'relay', source: { kind: 'agent-message', form: 'relay' }, label: 'agent-message' }, { form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' }, ] as const for (const { form, source, label } of cases) { @@ -741,13 +741,13 @@ describe('MessageItem arms', () => { kind: 'context', seq: 3, content: [{ type: 'text', text: 'child report body' }], - source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' }, - provenance: { role: 'inject', label: 'subagent-report' }, + source: { kind: 'agent-message', form: 'relay', senderSessionId: 'child-7' }, + provenance: { role: 'inject', label: 'agent-message' }, form: 'relay', } as never} />, ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ })) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-message$/ })) expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7') expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body') }) diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index b234cef408..89a7c5602c 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -7,6 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import { errorMessage, TeamError } from './error.ts' import type { TeamJournal } from './journal.ts' import type { TeamRuntimeLifecycle } from './lifecycle.ts' @@ -260,7 +261,7 @@ export class TeamMailbox { return true } } - await this.ctx.subagents.followup(root, message.targetId, content, { source, signal }) + await queueHostSubagentPrompt(this.ctx.subagents, root, message.targetId, content, source, signal) return target === undefined ? true : await this.checkpointDelivered(root, target.session, message.id) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index d5c471d665..313d6c9c86 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -10,6 +10,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId, type Session } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' +import { queueSubagentPrompt, type HostPromptQueue } from '@deepseek-ai/dsh-subagent/internal' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -1104,16 +1105,17 @@ describe('Team mailbox and waiting', () => { const entered = Promise.withResolvers() const release = Promise.withResolvers() const admitted: string[] = [] - vi.spyOn(ctx.subagents, 'followup').mockImplementation(async (_parent, _childId, blocks) => { - const last = blocks.at(-1) - const text = last?.type === 'text' ? last.text : '' - admitted.push(text) - if (text === 'first waking') { - entered.resolve(undefined) - await release.promise - } - return createUserMessage({ content: blocks, source: { kind: 'user' } }).id - }) + vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) + .mockImplementation(async (_parent, _childId, blocks) => { + const last = blocks.at(-1) + const text = last?.type === 'text' ? last.text : '' + admitted.push(text) + if (text === 'first waking') { + entered.resolve(undefined) + await release.promise + } + return createUserMessage({ content: blocks, source: { kind: 'user' } }).id + }) const first = ctx.agentTeams.sendMessage(lead, { target: 'ordered-target', content: content('first waking'), delivery: 'wakeup', signal: SIGNAL, @@ -1226,7 +1228,8 @@ describe('Team mailbox and waiting', () => { expect(uncertain.status).toBe('queued') inspect.mockRestore() - vi.spyOn(ctx.subagents, 'followup').mockRejectedValueOnce(new Error('delivery unavailable')) + vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) + .mockRejectedValueOnce(new Error('delivery unavailable')) const failed = await ctx.agentTeams.sendMessage(lead, { target: 'inactive-target', content: content('delivery failure'), delivery: 'wakeup', signal: SIGNAL, }) @@ -1532,18 +1535,19 @@ describe('Team mailbox and waiting', () => { const entered = Promise.withResolvers() const aborted = Promise.withResolvers() const release = Promise.withResolvers() - vi.spyOn(ctx.subagents, 'followup').mockImplementation(async (_parent, _childId, _content, options) => { - entered.resolve(undefined) - return await new Promise((_resolve, reject) => { - options.signal.addEventListener('abort', () => { - aborted.resolve(undefined) - void release.promise.then(() => { - const reason: unknown = options.signal.reason - reject(reason instanceof Error ? reason : new Error(String(reason))) - }) - }, { once: true }) + vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) + .mockImplementation(async (_parent, _childId, _content, _source, signal) => { + entered.resolve(undefined) + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted.resolve(undefined) + void release.promise.then(() => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error(String(reason))) + }) + }, { once: true }) + }) }) - }) const sending = ctx.agentTeams.sendMessage(lead, { target: 'mailbox-worker', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 00f2992f94..98fe3386a2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2123,11 +2123,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['when continuation services are unavailable or materialization fails.'], }, { - signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', - description: 'Deliver one later message to a continuable child as its next FIFO turn. A resident child\'s Agent inbox accepts it directly (waking a `waiting` Activation), while an absent one is cold-resumed from its persisted Session. The Agent inbox is the only queue, so every accepted message has one observable order.', - parameters: [{ name: 'parent', description: 'the exact live direct parent authorizing this delivery.' }, { name: 'childId', description: 'durable child session id.' }, { name: 'content', description: 'user-role content to deliver.' }, { name: 'options', description: 'the message source fields and caller cancellation, which stops the operation only before inbox acceptance.' }], + signature: 'async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise', + description: 'Steer one model-authored message to the sender\'s direct parent or direct continuable child. A running target admits it at the nearest step boundary; an idle target starts a turn, and an absent direct child cold-resumes from persistence. The service derives durable sender attribution from the exact live sender. Caller cancellation stops only pre-acceptance work.', + parameters: [{ name: 'sender', description: 'exact live Agent authorizing and originating the message.' }, { name: 'targetId', description: 'durable direct-parent or direct-child session id.' }, { name: 'content', description: 'model-authored content to deliver.' }, { name: 'options', description: 'caller cancellation before inbox acceptance.' }], returns: 'the accepted message\'s inbox id.', - throws: ['when continuation services are unavailable, parent authority is rejected, or the message was not admitted.'], + throws: ['when continuation services are unavailable, adjacency is rejected, or the message was not admitted.'], }, { signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', @@ -2135,13 +2135,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'targetSessionId', description: 'the durable child session id to interrupt.' }, { name: 'authority', description: 'the human parent address or exact live ancestor Agent.' }], throws: ['{SubagentError} `UNAUTHORIZED` when the authority does not own the live target.'], }, - { - signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise', - description: 'Deliver selected content from one live continuable child to its durable direct parent. The child is the authority credential; callers cannot name a recipient. Reporting does not conclude the child\'s turn or Activation.', - parameters: [{ name: 'child', description: 'exact live reporting child.' }, { name: 'content', description: 'selected model-facing content.' }, { name: 'options', description: 'parent scheduling and pre-acceptance cancellation.' }], - returns: 'the stable identity of the parent-accepted message.', - throws: ['when continuation services are unavailable, sender authorization fails, or the direct parent is not live.'], - }, { signature: 'registerContinuableSetup(contribution: ContinuableSetupContribution): () => void', description: 'Compose one deployment capability into every continuable child\'s unpublished creation context on fresh creation and cold resume. Grants wait for the next Activation; removing the contribution revokes every resident installation immediately.', @@ -5279,10 +5272,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentDescriptorData', declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;', }, - { - name: 'SubagentFollowupOptions', - declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', - }, { name: 'SubagentInterruptAuthority', declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};', @@ -5311,14 +5300,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentProvider', declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n readonly agentRouteDefaults?: Readonly<{\n provider: string;\n model: string;\n }>;\n start(request: ResolvedSubagentStartRequest): Promise;\n prepareContinuable?(request: ContinuableCreateRequest): Promise;\n}', }, - { - name: 'SubagentReportDelivery', - declaration: 'export type SubagentReportDelivery = \'quiet\' | \'next-step\';', - }, - { - name: 'SubagentReportOptions', - declaration: 'export interface SubagentReportOptions {\n readonly delivery: SubagentReportDelivery;\n readonly signal: AbortSignal;\n}', - }, { name: 'SubagentResult', declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly diagnostic?: string;\n readonly stopReason: SubagentStopReason;\n}', @@ -5341,7 +5322,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | un /* …truncated — full shape in source */', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise @@ -124,7 +125,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -One user-role parent message opening with the outcome — `Background subagent finished and will do no further work unless you send it more.`, or the matching line for a child that was stopped, ran out of room, declined, or failed — followed by `Its closing message:` and the child's final assistant content, or `It left no closing message.` when it produced none. This is the service's only direct parent-side contribution; delegation schemas, parent continuation and discovery, and the child-scoped `report` belong to `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. +One user-role parent message opening with the outcome — `Background subagent finished and will do no further work unless you send it more.`, or the matching line for a child that was stopped, ran out of room, declined, or failed — followed by `Its closing message:` and the child's final assistant content, or `It left no closing message.` when it produced none. This runtime-owned notice is distinct from model-authored parent/child messages, which use `sendMessage()` and `AgentMessageSource`; delegation schemas, model controls, and the temporary child-scoped `report` adapter belong to the Consumer packages. #### Token effect @@ -162,7 +163,7 @@ Prefix-stable within a child: the statement never changes during the child's lif These limits define when the seam is a poor fit or needs special operational care. They are current package constraints, not a general delegation comparison or a task backlog. - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus, and remote providers need an Activation ownership contract before they can support continuable children. -- **No host-user continuation** — `followup()` requires the exact live direct parent; only `interrupt()` accepts a durable human parent address. +- **Adjacent model messaging only** — `sendMessage()` requires an exact live sender and a direct parent or direct continuable child; browser prompts use the separate Queue control path. - **Continuation messages never steer** — parent-to-child follow-ups enqueue later turns; they never redirect the child's current turn. - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c4deb001c4..3e50c6aa50 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -76,7 +76,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 服务入口:提供方注册表、启动与继续 API、生命周期事件 | -| [`src/continuation.ts`](src/continuation.ts) | 可继续子级:身份预留、Activation 驻留、后续消息、中断、结算 | +| [`src/continuation.ts`](src/continuation.ts) | 可继续子级:身份预留、Activation 驻留、相邻消息、中断、结算 | +| [`src/internal.ts`](src/internal.ts) | 供浏览器与 Team 消息协议使用的 host-only Queue 适配器 | | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 | | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 | | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 | @@ -90,13 +91,13 @@ kind: "package-reference" ### 可继续流程 -管理器预留子 agent 身份、解析持久化描述符、创建(或冷恢复)子 agent、把它安装进 Activation 并提交提示词。后续消息经子 agent 自己的 inbox 成为 FIFO 轮次;没有 Activation 时从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在父级自身的轮次流中告知该子级的直接父级。 +管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;host 协议保留内部 Queue 适配器以创建独立轮次。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 ### 所有权与不变式 - **发布即边界**——发布前提供方拥有设置并须在失败时回滚;发布后调用方拥有运行并须 dispose(资源释放)它。 - **注册受 effect 作用域约束**——移除提供方会阻止新启动,但绝不撤销已接受的运行。 -- **继续执行权限基于确切身份**——后续消息要求确切在线直接父级;上报要求确切在线子级。 +- **Agent 消息权限基于确切相邻关系**——`sendMessage()` 要求确切在线 sender,以及其直接 parent 或直接可继续 child。 - **描述符仅进日志**——它是会话事件,不进入模型历史,并跨压缩(compaction)保留;可继续描述符会显式记录解析后的子级提供方、模型与推理等级,用于冷恢复。 @@ -124,7 +125,7 @@ kind: "package-reference" #### 模型看到什么 -一条用户角色的父级消息,开头是结果本身——`Background subagent finished and will do no further work unless you send it more.`,或子级被停止、耗尽额度、拒绝任务或失败时的对应句子——随后是 `Its closing message:` 与子级的最终 assistant 内容;若子级没有产出内容,则是 `It left no closing message.`。这是本服务面向父级的唯一直接贡献;委派 schema、父级延续与发现以及子级作用域的 `report` 分别归 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 所有。 +一条用户角色的父级消息,开头是结果本身——`Background subagent finished and will do no further work unless you send it more.`,或子级被停止、耗尽额度、拒绝任务或失败时的对应句子——随后是 `Its closing message:` 与子级的最终 assistant 内容;若子级没有产出内容,则是 `It left no closing message.`。这条由 runtime 生成的通知与模型编写的父子消息相互独立;后者使用 `sendMessage()` 与 `AgentMessageSource`。委派 schema、模型控制工具与临时的 child 作用域 `report` 适配器归 Consumer 包所有。 #### Token 影响 @@ -162,7 +163,7 @@ You are a delegated subagent: your permission scope was fixed when you were star 这些限制说明该 seam 何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用委派对比或任务积压。 - **ACP 子级仍为一次性,且无法通过追踪枚举**——ACP 运行在父级会话语料中没有本地子会话,远程提供方需要 Activation 所有权约定才能支持可继续子级。 -- **无 host-user 继续执行**——`followup()` 要求确切在线直接父级;只有 `interrupt()` 接受持久化的人类父级地址。 +- **仅允许相邻模型消息**——`sendMessage()` 要求确切在线 sender,以及直接 parent 或直接可继续 child;浏览器提示使用独立的 Queue 控制路径。 - **继续执行消息绝不 steering(中途引导)**——父到子的后续消息排入后续轮次;它们绝不会重定向子级当前轮次。 - **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index c89f11eee7..143d384a3e 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -18,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./internal": { + "types": "./lib/types/internal.d.ts", + "default": "./lib/types/internal.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 6b50947060..ee3b4ef319 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -55,28 +55,19 @@ import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts' import { SubagentError } from './error.ts' import type SubagentActivationSetupRegistry from './activation-setup-registry.ts' -/** Attribution for a model coordinator's follow-up to one of its children. */ -export interface CoordinatorMessageSource { - readonly kind: 'coordinator' +/** Durable attribution for one model-authored message between adjacent Agents. */ +export interface AgentMessageSource { + readonly kind: 'agent-message' /** A message another agent addressed to this one (`relay` context form). */ readonly form: 'relay' - /** Session id of the agent whose tool call produced the follow-up. */ - readonly senderSessionId: SessionId -} - -/** Durable attribution for a continuable child's explicit parent report. */ -export interface SubagentReportMessageSource { - readonly kind: 'subagent-report' - /** A message another agent addressed to this one (`relay` context form). */ - readonly form: 'relay' - /** Session id of the reporting child. */ + /** Session id of the Agent whose tool call produced the message. */ readonly senderSessionId: SessionId } /** * Durable attribution for the runtime's own account of a continuable child * settling. Deliberately a different kind from - * {@link SubagentReportMessageSource}: a report is content the child chose, + * {@link AgentMessageSource}: an Agent message is content the sender chose, * while this message is the manager stating what became of the child, and a * transcript that merged them would credit the child with words it never wrote. */ @@ -92,23 +83,11 @@ export interface SubagentSettledMessageSource { declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - coordinator: CoordinatorMessageSource - 'subagent-report': SubagentReportMessageSource + 'agent-message': AgentMessageSource 'subagent-settled': SubagentSettledMessageSource } } -/** Deployment scheduling policy for accepted child reports. */ -export type SubagentReportDelivery = 'quiet' | 'next-step' - -/** Options for one continuable child's report to its direct parent. */ -export interface SubagentReportOptions { - /** Already-resolved parent scheduling policy. */ - readonly delivery: SubagentReportDelivery - /** Caller cancellation, owning authorization and admission until acceptance. */ - readonly signal: AbortSignal -} - /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */ @@ -147,14 +126,22 @@ export type SubagentInterruptAuthority = | { readonly kind: 'user'; readonly parentSessionId: SessionId } | { readonly kind: 'ancestor'; readonly agent: Agent } -/** Options for following up with one continuable child. */ -export interface SubagentFollowupOptions { - /** Durable attribution retained on the delivered message; it grants no authority. */ - readonly source: MessageSource +/** Options for one model-authored message between adjacent Agents. */ +export interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } +/** Private scheduling choice for one direct-child delivery. */ +type ChildDelivery = 'queue' | 'steer' + +/** Inputs shared by model steering and the human Queue adapter. */ +interface ChildDeliveryOptions { + readonly source: MessageSource + readonly signal: AbortSignal + readonly delivery: ChildDelivery +} + /** * The residency state of one continuable child, derived from Agent quiescence * and the owned-child set rather than a second state machine: @@ -232,7 +219,7 @@ interface Activation { disposal: Promise | undefined /** * Accepted waking message ids this manager has not yet seen leave the inbox. - * `Agent.status` is still `idle` in the window between `followup()` and the + * `Agent.status` is still `idle` in the window between a waking send and the * microtask that admits it, so settlement must not treat that gap as quiet. */ readonly accepted: Set @@ -288,6 +275,26 @@ function disposalOf(activation: Activation): Promise | undefined { return activation.disposal } +/** Build durable attribution for one adjacent-Agent message. */ +function agentMessageSource(sender: Agent): AgentMessageSource { + return { + kind: 'agent-message', + form: 'relay', + senderSessionId: sender.id, + } +} + +/** Build the model-visible and durable representation of one adjacent-Agent message. */ +function agentMessage(sender: Agent, content: ContentBlock[]) { + return createUserMessage({ + content: [ + { type: 'text' as const, text: `Agent ${sender.id} sent a message:` }, + ...content, + ], + source: agentMessageSource(sender), + }) +} + /** * One line telling a parent that a background child is finished and why, in * the parent's own task vocabulary. @@ -471,9 +478,8 @@ export class SubagentContinuationManager { return this.submitMaterialized( activation, request.prompt, - { kind: 'user' }, + { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, parent, - spec.signal, ) }) return { childId, messageId } @@ -487,27 +493,70 @@ export class SubagentContinuationManager { } /** - * Deliver one later message to a known continuable child as its next FIFO - * turn. Routing depends only on Activation residency: a `running` Activation - * enqueues, a `waiting` one wakes the same Agent, and an absent one - * cold-resumes a new Activation from the persisted Session. The Agent inbox - * is the only queue, so every accepted message has one observable order. - * - * The caller signal owns lookup, materialization, and admission only until - * inbox acceptance; afterwards the accepted turn cannot be cancelled through - * this service. - * @param parent - the exact live direct parent authorizing this delivery. - * @param childId - the durable child session id. - * @param content - the user-role content to deliver. - * @param options - the message source fields and caller cancellation. + * Deliver one model-authored message to a direct continuable child or to the + * sender's direct parent. Both directions use Steer: a running target admits + * the message at its nearest step boundary, while an idle target starts a + * turn. A missing direct child cold-resumes through the ordinary continuation + * lifecycle. The caller signal owns the operation only until inbox acceptance. + * @param sender - exact live Agent authorizing and originating the message. + * @param targetId - durable direct-parent or direct-child session id. + * @param content - model-authored content to deliver. + * @param options - caller cancellation before acceptance. * @returns the accepted message's inbox id. - * @throws when parent authority, availability, or admission rejects the delivery. + * @throws when adjacency, availability, or admission rejects delivery. */ - async followup( + async sendMessage( + sender: Agent, + targetId: SessionId, + content: ContentBlock[], + options: SubagentSendMessageOptions, + ): Promise { + if (this.ctx.agents.get(sender.id) !== sender) { + throw new SubagentError( + 'message delivery requires the exact live sender agent', + 'UNAUTHORIZED', + ) + } + this.assertAdmitting(sender) + const senderActivation = this.activations.get(sender.id) + if (senderActivation !== undefined + && senderActivation.handle.agent === sender + && senderActivation.parentSession === targetId) { + options.signal.throwIfAborted() + return this.sendToParent(senderActivation, sender, content) + } + return this.deliverToChild(sender, targetId, content, { + source: agentMessageSource(sender), + signal: options.signal, + delivery: 'steer', + }) + } + + /** + * Queue one human-authored prompt as a distinct direct-child turn. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - human-authored content to deliver. + * @param source - durable host-protocol provenance. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted message's inbox id. + */ + async queuePrompt( parent: Agent, childId: SessionId, content: ContentBlock[], - options: SubagentFollowupOptions, + source: MessageSource, + signal: AbortSignal, + ): Promise { + return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'queue' }) + } + + /** Route one parent-originated delivery through residency and cold resume. */ + private async deliverToChild( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: ChildDeliveryOptions, ): Promise { this.assertAdmitting(parent) while (true) { @@ -523,7 +572,7 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } - return this.submitAdmitted(activation, content, options.source, parent, options.signal) + return this.submitAdmitted(activation, content, options, parent) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that * race reaches the retry below, which then cold-resumes a new Activation. */ @@ -597,88 +646,29 @@ export class SubagentContinuationManager { ) } - /** - * Deliver explicitly selected content from one resident continuable child to - * its durable direct parent. Sender authorization, parent resolution, and - * send acceptance share one no-await span. Reporting neither concludes the - * child's turn nor changes its Activation lifetime. - * @param child - exact live reporting child; this is the authority credential. - * @param content - selected model-facing content. - * @param options - scheduling policy and pre-acceptance cancellation. - * @returns the stable identity of the message accepted by the parent. - * @throws {SubagentError} when the sender is unauthorized, the parent is not - * live, or continuation admission is closing. - */ - // oxlint-disable-next-line typescript/require-await -- keep rejection semantics without yielding during admission - async reportFrom( - child: Agent, + /** Deliver one resident continuable child's message to its live direct parent. */ + private sendToParent( + activation: Activation, + sender: Agent, content: ContentBlock[], - options: SubagentReportOptions, - ): Promise { - options.signal.throwIfAborted() - this.assertAdmitting(child) - const activation = this.authorizeReporter(child) - const parent = this.resolveReportParent(child) - return this.deliverReport(activation, parent, content, options.delivery) - } - - /** Authorize only the exact Agent of one resident Activation. */ - private authorizeReporter(child: Agent): Activation { - const activation = this.activations.get(child.id) - if (activation === undefined || activation.handle.agent !== child) { - throw new SubagentError( - `agent "${child.id}" is not a live continuable subagent and cannot report`, - 'UNAUTHORIZED', - ) - } - /* v8 ignore next 6 -- only a synchronous re-entrant disposer can open this - * transaction between exact-agent authorization and this no-await cutoff. */ + ): MessageId { + /* v8 ignore next 6 -- only synchronous re-entrant teardown can open this + * transaction between exact-agent authorization and this no-await span. */ if (activation.disposal !== undefined) { throw new SubagentError( - `subagent "${child.id}" activation is being disposed; the report was not delivered`, + `subagent "${sender.id}" activation is being disposed; the message was not delivered`, 'ACTIVATION_CLOSING', ) } - return activation - } - - /** Resolve the reporting child's live direct parent from durable lineage. */ - private resolveReportParent(child: Agent): Agent { - const parentId = child.session.header.parentSession - /* v8 ignore next -- every continuation-managed child has direct-parent metadata. */ - const parent = parentId === undefined ? undefined : this.ctx.agents.get(parentId) + const parent = this.ctx.agents.get(activation.parentSession) if (parent === undefined) { throw new SubagentError( - 'direct parent is not live; report was not delivered', + 'direct parent is not live; the message was not delivered', 'PARENT_UNAVAILABLE', ) } - return parent - } - - /** Deliver one framed report through the selected parent scheduling preset. */ - private deliverReport( - activation: Activation, - parent: Agent, - content: ContentBlock[], - delivery: SubagentReportDelivery, - ): MessageId { - const message = createUserMessage({ - content: [ - { type: 'text' as const, text: `Background subagent ${activation.childId} reported:` }, - ...content, - ], - source: { - kind: 'subagent-report' as const, - form: 'relay' as const, - senderSessionId: activation.childId, - }, - }) - if (delivery === 'next-step') { - this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery) }) - } else { - this.sendReport(parent, message, delivery) - } + const message = agentMessage(sender, content) + this.sendWaking(parent, message, () => { this.sendAgentMessage(parent, message) }) return message.id } @@ -704,18 +694,16 @@ export class SubagentContinuationManager { } } - /** Send one report while translating only the parent's own rejection. */ - private sendReport( + /** Send one Agent message while translating only the target's own rejection. */ + private sendAgentMessage( parent: Agent, message: ReturnType, - delivery: SubagentReportDelivery, ): void { try { - if (delivery === 'next-step') parent.steer(message) - else parent.inject(message) + parent.steer(message) } catch (error: unknown) { throw new SubagentError( - 'direct parent is not live; report was not delivered', + 'direct parent is not live; the message was not delivered', 'PARENT_UNAVAILABLE', { cause: error }, ) @@ -950,7 +938,7 @@ export class SubagentContinuationManager { parent: Agent, childId: SessionId, content: ContentBlock[], - options: SubagentFollowupOptions, + options: ChildDeliveryOptions, ): Promise { const query = this.requireSessionQuery() let observation: SessionObservation @@ -1001,27 +989,25 @@ export class SubagentContinuationManager { if (error instanceof SubagentError) throw error throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) } - return await this.submitMaterialized(activation, content, options.source, parent, options.signal) + return await this.submitMaterialized(activation, content, options, parent) } /** * Submit to a freshly materialized Activation or roll it back completely. * @param activation - the just-published Activation to admit or release. * @param content - the initial or resumed message content. - * @param source - durable fields naming who supplied the accepted message. + * @param options - durable source, scheduling, and pre-acceptance cancellation. * @param parent - the live direct parent authorizing admission. - * @param signal - caller cancellation owning admission until acceptance. * @returns the accepted inbox message id. */ private async submitMaterialized( activation: Activation, content: ContentBlock[], - source: MessageSource, + options: ChildDeliveryOptions, parent: Agent, - signal: AbortSignal, ): Promise { try { - return this.submitAdmitted(activation, content, source, parent, signal) + return this.submitAdmitted(activation, content, options, parent) } catch (error: unknown) { /* v8 ignore next -- rollback disposal failures must not mask the * pre-acceptance signal, drain, or lifecycle failure. */ @@ -1203,15 +1189,18 @@ export class SubagentContinuationManager { private submit( activation: Activation, content: ContentBlock[], - source: MessageSource, + options: ChildDeliveryOptions, parent: Agent, ): MessageId { // Parent-originated delivery keeps the parent live through ownership, so // establish it before the message can enter the child's inbox. this.acquireOwnership(parent, activation.childId) - const message = createUserMessage({ content, source }) + const message = options.source.kind === 'agent-message' + ? agentMessage(parent, content) + : createUserMessage({ content, source: options.source }) const accepted = this.admitWaking(activation, message.id, () => { - activation.handle.agent.followup(message) + if (options.delivery === 'steer') activation.handle.agent.steer(message) + else activation.handle.agent.followup(message) }) // Past this point the caller has an id for this child, so its eventual // settlement is something the parent is owed an account of. @@ -1254,11 +1243,10 @@ export class SubagentContinuationManager { private submitAdmitted( activation: Activation, content: ContentBlock[], - source: MessageSource, + options: ChildDeliveryOptions, parent: Agent, - signal: AbortSignal, ): MessageId { - signal.throwIfAborted() + options.signal.throwIfAborted() this.assertAdmitting(parent) /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change * this field between the caller's live check and this no-await boundary. */ @@ -1273,7 +1261,7 @@ export class SubagentContinuationManager { activation.childId, activation.handle.agent.session.header.parentSession, ) - return this.submit(activation, content, source, parent) + return this.submit(activation, content, options, parent) } /** diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 444f4ee857..e8f38613ed 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,8 +13,8 @@ * * Public operations express caller intent: `start` returns one published owned * one-shot run, `startContinuable` establishes a durable continuable child, and - * `followup` delivers later content without exposing whether the child is - * resident. Continuable children never become a {@link SubagentRun}: the + * `sendMessage` steers between adjacent Agents without exposing whether a child + * is resident. Continuable children never become a {@link SubagentRun}: the * continuation manager holds their `AgentHandle` directly and orders every turn * through the child's own inbox, so providers contribute only the detached * creation spec and see no handle, turn, or teardown. Child and descendant @@ -33,7 +33,7 @@ import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -67,9 +67,8 @@ import SubagentContinuationManager from './continuation.ts' import type { ContinuableStart, ContinuableStartSpec, - SubagentFollowupOptions, SubagentInterruptAuthority, - SubagentReportOptions, + SubagentSendMessageOptions, } from './continuation.ts' import SubagentActivationSetupRegistry from './activation-setup-registry.ts' import type { ContinuableSetupContribution } from './activation-setup-registry.ts' @@ -77,6 +76,7 @@ import { listChildren as listSubagentChildren, listDescendants as listSubagentDe import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' +import { queueSubagentPrompt } from './internal.ts' export * from './out-of-process.ts' export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' @@ -122,14 +122,11 @@ export { } from './child-agent.ts' export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts' export type { + AgentMessageSource, ContinuableStart, ContinuableStartSpec, - CoordinatorMessageSource, - SubagentFollowupOptions, SubagentInterruptAuthority, - SubagentReportDelivery, - SubagentReportMessageSource, - SubagentReportOptions, + SubagentSendMessageOptions, SubagentSettledMessageSource, } from './continuation.ts' export type { ContinuableSetupContribution } from './activation-setup-registry.ts' @@ -239,27 +236,47 @@ export class SubagentRuntime extends TypertRemoteService { } /** - * Deliver one later message to a continuable child as its next FIFO turn. A - * resident child's Agent inbox accepts it directly (waking a `waiting` - * Activation), while an absent one is cold-resumed from its persisted - * Session. The Agent inbox is the only queue, so every accepted message has - * one observable order. - * @param parent - the exact live direct parent authorizing this delivery. - * @param childId - durable child session id. - * @param content - user-role content to deliver. - * @param options - the message source fields and caller cancellation, which stops the - * operation only before inbox acceptance. + * Steer one model-authored message to the sender's direct parent or direct + * continuable child. A running target admits it at the nearest step boundary; + * an idle target starts a turn, and an absent direct child cold-resumes from + * persistence. The service derives durable sender attribution from the exact + * live sender. Caller cancellation stops only pre-acceptance work. + * @param sender - exact live Agent authorizing and originating the message. + * @param targetId - durable direct-parent or direct-child session id. + * @param content - model-authored content to deliver. + * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, parent authority is - * rejected, or the message was not admitted. + * @throws when continuation services are unavailable, adjacency is rejected, + * or the message was not admitted. */ - async followup( + async sendMessage( + sender: Agent, + targetId: SessionId, + content: ContentBlock[], + options: SubagentSendMessageOptions, + ): Promise { + return this.requireContinuations().sendMessage(sender, targetId, content, options) + } + + /** + * Queue one host-protocol message as a distinct direct-child turn. + * Symbol-keyed so host adapters can preserve their own provenance without + * widening the public Service Definition or impersonating an Agent sender. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - host-authored content to deliver. + * @param source - durable host-protocol provenance. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted message's inbox id. + */ + private [queueSubagentPrompt]( parent: Agent, childId: SessionId, content: ContentBlock[], - options: SubagentFollowupOptions, + source: MessageSource, + signal: AbortSignal, ): Promise { - return this.requireContinuations().followup(parent, childId, content, options) + return this.requireContinuations().queuePrompt(parent, childId, content, source, signal) } /** @@ -281,25 +298,6 @@ export class SubagentRuntime extends TypertRemoteService { this.continuations?.interrupt(targetSessionId, authority) } - /** - * Deliver selected content from one live continuable child to its durable - * direct parent. The child is the authority credential; callers cannot name a - * recipient. Reporting does not conclude the child's turn or Activation. - * @param child - exact live reporting child. - * @param content - selected model-facing content. - * @param options - parent scheduling and pre-acceptance cancellation. - * @returns the stable identity of the parent-accepted message. - * @throws when continuation services are unavailable, sender authorization - * fails, or the direct parent is not live. - */ - async reportFrom( - child: Agent, - content: ContentBlock[], - options: SubagentReportOptions, - ): Promise { - return this.requireContinuations().reportFrom(child, content, options) - } - /** * Compose one deployment capability into every continuable child's * unpublished creation context on fresh creation and cold resume. Grants wait @@ -455,7 +453,15 @@ export class SubagentRuntime extends TypertRemoteService { } const content: ContentBlock[] = [...request.content] try { - return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } + return { + messageId: await this[queueSubagentPrompt]( + parent, + childSessionId, + content, + source, + signal, + ), + } } catch (error: unknown) { return rejectPrompt(error, childSessionId, signal) } diff --git a/packages/subagent/subagent/src/internal.ts b/packages/subagent/subagent/src/internal.ts new file mode 100644 index 0000000000..d0e1c47dc9 --- /dev/null +++ b/packages/subagent/subagent/src/internal.ts @@ -0,0 +1,55 @@ +/** + * Host-only continuation adapters outside the public Service Definition and + * model-facing Agent messaging contract. + * @module @deepseek-ai/dsh-subagent/internal + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type SubagentRuntime from './index.ts' + +/** + * Process-stable symbol-keyed Queue delivery shared by the bundled runtime + * entry and this unbundled internal subpath. + * @internal + */ +export const queueSubagentPrompt = Symbol.for('dsh.subagent.queuePrompt') + +/** Runtime face required by the host-only Queue adapter. */ +export interface HostPromptQueue { + [queueSubagentPrompt]( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: MessageSource, + signal: AbortSignal, + ): Promise +} + +/** + * Queue one host-protocol message without exposing another Service operation. + * @param runtime - subagent runtime owning continuation residency. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - host-authored content to deliver. + * @param source - durable host-protocol provenance. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted message's inbox id. + */ +export function queueHostSubagentPrompt( + runtime: SubagentRuntime, + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: MessageSource, + signal: AbortSignal, +): Promise { + return (runtime as unknown as HostPromptQueue)[queueSubagentPrompt]( + parent, + childId, + content, + source, + signal, + ) +} diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index f37ec63c31..f6cb148e6e 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -15,6 +15,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -191,10 +192,24 @@ describe('continuable policy inheritance', () => { // The parent widens AFTER the child was created; the resumed child keeps // the delegation-time snapshot from its own log. setSandboxMode(parent.session, 'danger-full-access') - await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], { - source: { kind: 'user' }, - signal: new AbortController().signal, - }) + const manager = (ctx.subagents as unknown as { + continuations: { + queuePrompt( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: { kind: 'user' }, + signal: AbortSignal, + ): Promise + } + }).continuations + await manager.queuePrompt( + parent, + started.childId, + [{ type: 'text', text: 'continue please' }], + { kind: 'user' }, + new AbortController().signal, + ) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9508065197..7d07d675d9 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -127,17 +127,26 @@ function userTexts(events: readonly SessionEvent[]): string[] { : []) } -function followup( +function queuePrompt( ctx: Context, parent: Agent, childId: SessionId, content: ReturnType, signal: AbortSignal = testSignal, ) { - return ctx.subagents.followup(parent, childId, content, { - source: { kind: 'user' }, - signal, - }) + const manager = (ctx.subagents as unknown as { + continuations?: { + queuePrompt( + parent: Agent, + childId: SessionId, + content: ReturnType, + source: { kind: 'user' }, + signal: AbortSignal, + ): Promise + } + }).continuations + if (manager === undefined) throw new Error('expected a bound continuation manager') + return manager.queuePrompt(parent, childId, content, { kind: 'user' }, signal) } /** @@ -317,7 +326,7 @@ describe('SubagentRuntime.startContinuable', () => { expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) .toMatchObject({ agentReasoningEffort: 'max' }) - await followup(ctx, parent, started.childId, message('resume selected reasoning')) + await queuePrompt(ctx, parent, started.childId, message('resume selected reasoning')) await waitNoActivation(ctx, started.childId) expect(childEfforts).toEqual(['max', 'max']) }) @@ -464,7 +473,7 @@ describe('SubagentRuntime.startContinuable', () => { await fresh.plugin(SubagentRuntime) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) const freshParent = fresh.agentLoop.create(SessionId('routeless-resume'), {}) - await followup(fresh, freshParent, started.childId, message('resume routeless')) + await queuePrompt(fresh, freshParent, started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { const found = fresh.agents.get(started.childId) @@ -516,21 +525,21 @@ describe('SubagentRuntime.startContinuable', () => { expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' }) // Cold resume reconstructs the declared composition from that descriptor. - await followup(ctx, parent, started.childId, message('resume it')) + await queuePrompt(ctx, parent, started.childId, message('resume it')) await waitNoActivation(ctx, started.childId) const resumed = await ctx.sessionPersistence.load(started.childId) expect(hasUserText(resumed.events, 'resume it')).toBe(true) }) }) -describe('SubagentRuntime.followup residency routing', () => { +describe('direct-child Queue residency routing', () => { it('fails a cold follow-up when Session query is unavailable', async () => { const { ctx, parent } = await setupWith(new MockAdapter([]), { persistence: false, sessionQuery: false, }) - await expect(followup(ctx, parent, SessionId('cold-without-query'), message('continue'))) + await expect(queuePrompt(ctx, parent, SessionId('cold-without-query'), message('continue'))) .rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) }) @@ -548,8 +557,8 @@ describe('SubagentRuntime.followup residency routing', () => { expect(child?.status).toBe('running') // Both messages queue behind the open turn, in call order. - const firstMessage = await followup(ctx, parent, started.childId, message('first follow-up')) - const secondMessage = await followup(ctx, parent, started.childId, message('second follow-up')) + const firstMessage = await queuePrompt(ctx, parent, started.childId, message('first follow-up')) + const secondMessage = await queuePrompt(ctx, parent, started.childId, message('second follow-up')) expect(firstMessage).not.toBe(secondMessage) // Still the same Activation: no second child Agent was created. expect(ctx.agents.get(started.childId)).toBe(child) @@ -565,7 +574,7 @@ describe('SubagentRuntime.followup residency routing', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - const messageId = await followup(ctx, parent, started.childId, message('continue please')) + const messageId = await queuePrompt(ctx, parent, started.childId, message('continue please')) expect(messageId).toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -596,7 +605,7 @@ describe('SubagentRuntime.followup residency routing', () => { disposeProvider() expect(ctx.subagents.getProvider('retired')).toBeUndefined() - await expect(followup(ctx, parent, started.childId, message('continue without provider'))) + await expect(queuePrompt(ctx, parent, started.childId, message('continue without provider'))) .resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) @@ -632,7 +641,7 @@ describe('SubagentRuntime.followup residency routing', () => { // Waiting retains the handle: the same Agent is still live. expect(ctx.agents.get(started.childId)).toBe(child) - await followup(ctx, parent, started.childId, message('while waiting')) + await queuePrompt(ctx, parent, started.childId, message('while waiting')) // Woken back to running on the SAME Activation. expect(ctx.agents.get(started.childId)).toBe(child) @@ -652,7 +661,7 @@ describe('SubagentRuntime.followup residency routing', () => { await waitNoActivation(ctx, started.childId) const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) - await expect(followup(ctx, stranger, started.childId, message('mine now'))) + await expect(queuePrompt(ctx, stranger, started.childId, message('mine now'))) .rejects.toThrow(/belongs to another parent session/) }) @@ -670,13 +679,13 @@ describe('SubagentRuntime.followup residency routing', () => { const oneShotId = run.id await run.dispose() - await expect(followup(ctx, parent, oneShotId, message('continue'))) + await expect(queuePrompt(ctx, parent, oneShotId, message('continue'))) .rejects.toThrow(/no supported continuation state/) }) it('reports an unknown child id as unavailable', async () => { const { ctx, parent } = await setup([]) - await expect(followup(ctx, parent, SessionId('missing'), message('hello'))) + await expect(queuePrompt(ctx, parent, SessionId('missing'), message('hello'))) .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) @@ -701,7 +710,7 @@ describe('SubagentRuntime.followup residency routing', () => { const reason = new Error('cold inspection cancelled') try { - const delivery = followup(ctx, parent, started.childId, message('cancel me'), controller.signal) + const delivery = queuePrompt(ctx, parent, started.childId, message('cancel me'), controller.signal) await inspectStarted.promise controller.abort(reason) await expect(delivery).rejects.toBe(reason) @@ -717,7 +726,7 @@ describe('SubagentRuntime.followup residency routing', () => { const failure = new SubagentError('materialization denied', 'UNAUTHORIZED') ctx.agents.resume = () => Promise.reject(failure) - await expect(followup(ctx, parent, started.childId, message('continue'))) + await expect(queuePrompt(ctx, parent, started.childId, message('continue'))) .rejects.toBe(failure) }) @@ -733,7 +742,7 @@ describe('SubagentRuntime.followup residency routing', () => { // exactly one side wins the cutoff. A delivery that loses awaits release and // cold-resumes rather than reaching a handle being torn down. const delivery = child.whenIdle().then(() => - followup(ctx, parent, started.childId, message('raced'))) + queuePrompt(ctx, parent, started.childId, message('raced'))) await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -916,11 +925,11 @@ describe('continuable durability and teardown', () => { expect(ctx.agents.get(target.childId)).toBe(targetChild) expect(ctx.agents.get(grandchild.childId)).toBeDefined() expect(ctx.agents.get(sibling.childId)).toBe(siblingChild) - await expect(followup(ctx, siblingParent, sibling.childId, message('still live'))) + await expect(queuePrompt(ctx, siblingParent, sibling.childId, message('still live'))) .resolves.toBeTypeOf('string') await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) - await expect(followup(ctx, parent, target.childId, message('too late'))) + await expect(queuePrompt(ctx, parent, target.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) releaseTarget.resolve(undefined) @@ -1167,7 +1176,7 @@ describe('continuable durability and teardown', () => { await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) - await expect(followup(ctx, parent, started.childId, message('too late'))) + await expect(queuePrompt(ctx, parent, started.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) }) @@ -1224,7 +1233,7 @@ describe('continuable durability and teardown', () => { }) observeCancel(child, () => { order.push('cancel') }) - const delivery = followup(ctx, parent, started.childId, message('before drain')) + const delivery = queuePrompt(ctx, parent, started.childId, message('before drain')) // Let the child-lock operation reach the live admission cutoff. Admission // and inbox submission must then complete in one synchronous span. await Promise.resolve() @@ -1243,7 +1252,7 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Accepted into the inbox, but this queued turn never opens. - await followup(ctx, parent, started.childId, message('never logged')) + await queuePrompt(ctx, parent, started.childId, message('never logged')) const drained = drainManager(ctx) hold.resolve(undefined) @@ -1281,7 +1290,7 @@ describe('continuable review regressions', () => { return handle }) - const delivery = followup( + const delivery = queuePrompt( ctx, originalParent.agent, started.childId, @@ -1321,7 +1330,7 @@ describe('continuable review regressions', () => { throw new Error('synthetic inbox failure') } - await expect(followup(ctx, parent, started.childId, message('throws'))) + await expect(queuePrompt(ctx, parent, started.childId, message('throws'))) .rejects.toThrow(/synthetic inbox failure/) expect(activation.accepted.size).toBe(0) @@ -1361,7 +1370,7 @@ describe('continuable review regressions', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, parent, started.childId, message('cancelled'), controller.signal)) + await expect(queuePrompt(ctx, parent, started.childId, message('cancelled'), controller.signal)) .rejects.toThrow() // Nothing was enqueued, so no later turn can carry it. @@ -1387,7 +1396,7 @@ describe('continuable review regressions', () => { // A cold resume is a new epoch: it must report its OWN answer, never the // previous epoch's, which the replayed transcript still contains. - await followup(ctx, parent, started.childId, message('again')) + await queuePrompt(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) @@ -1443,7 +1452,7 @@ describe('continuable review regressions', () => { if (subject === parent) return next() return { kind: 'reject' } }) - await followup(ctx, parent, started.childId, message('again')) + await queuePrompt(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -1567,7 +1576,7 @@ describe('continuable review regressions', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Queue a turn, then cancel so it is discarded rather than dequeued. The // Activation must still reach settlement instead of waiting on that id. - await followup(ctx, parent, started.childId, message('discarded')) + await queuePrompt(ctx, parent, started.childId, message('discarded')) const drained = drainManager(ctx) hold.resolve(undefined) @@ -1587,13 +1596,13 @@ describe('continuable review regressions', () => { const child = ctx.agents.get(started.childId)! // Cancel from the synchronous enqueue observer: the discard fires after the - // id is recorded but before `followup()` returns. + // id is recorded but before `queuePrompt()` returns. const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) - await followup(ctx, parent, started.childId, message('doomed')) + await queuePrompt(ctx, parent, started.childId, message('doomed')) off() releaseFirst.resolve(undefined) @@ -1618,14 +1627,14 @@ describe('continuable review regressions', () => { }).continuations const activation = manager.activations.get(started.childId)! - await followup(ctx, parent, started.childId, message('queued')) + await queuePrompt(ctx, parent, started.childId, message('queued')) expect(activation.accepted.size).toBe(1) const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) - await followup(ctx, parent, started.childId, message('doomed')) + await queuePrompt(ctx, parent, started.childId, message('doomed')) off() expect(activation.accepted.size).toBe(0) @@ -1673,7 +1682,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId) - await followup(ctx, parent, started.childId, message('queued')) + await queuePrompt(ctx, parent, started.childId, message('queued')) expect(registeredAtEnqueue.length).toBeGreaterThan(0) expect(registeredAtEnqueue).not.toContain(false) @@ -1701,8 +1710,8 @@ function settlementNotices(agent: Agent): { sender: string; text: string; summar }) } -describe('continuable report delivery', () => { - it('wakes an idle parent for a next-step report', async () => { +describe('continuable adjacent-Agent delivery', () => { + it('steers an idle direct parent and preserves sender attribution', async () => { const releaseChild = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child answer'), gate: releaseChild.promise }, @@ -1717,17 +1726,25 @@ describe('continuable report delivery', () => { const child = ctx.agents.get(started.childId) expect(child).toBeDefined() - const messageId = await ctx.subagents.reportFrom(child!, message('an explicit report'), { - delivery: 'next-step', + const messageId = await ctx.subagents.sendMessage(child!, parent.id, message('an explicit message'), { signal: testSignal, }) await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) }) - const report = parent.session.events.flatMap(event => event.type === 'user/message' - && event.data.source.kind === 'subagent-report' ? [event.data] : [])[0] - expect(report?.id).toBe(messageId) + const delivered = parent.session.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'agent-message' ? [event.data] : [])[0] + expect(delivered?.id).toBe(messageId) + expect(delivered?.source).toMatchObject({ + kind: 'agent-message', + form: 'relay', + senderSessionId: started.childId, + }) + expect(delivered?.content).toEqual([ + { type: 'text', text: `Agent ${started.childId} sent a message:` }, + { type: 'text', text: 'an explicit message' }, + ]) releaseChild.resolve(undefined) await waitNoActivation(ctx, started.childId) @@ -1756,7 +1773,7 @@ describe('continuable settlement delivery', () => { ) }) - it('delivers even when the child already reported for itself', async () => { + it('delivers settlement even when the child already sent a message', async () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) const child = await vi.waitFor(() => { @@ -1764,8 +1781,7 @@ describe('continuable settlement delivery', () => { expect(live).toBeDefined() return live! }) - await ctx.subagents.reportFrom(child, message('an explicit report'), { - delivery: 'quiet', + await ctx.subagents.sendMessage(child, parent.id, message('an explicit message'), { signal: testSignal, }) await waitNoActivation(ctx, started.childId) @@ -1823,7 +1839,7 @@ describe('continuable settlement delivery', () => { }) const started = await ctx.subagents.startContinuable(startSpec(parent)) - await followup(ctx, parent, started.childId, message('second task')) + await queuePrompt(ctx, parent, started.childId, message('second task')) releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) @@ -1857,7 +1873,7 @@ describe('continuable settlement delivery', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) // Queued while turn 1 still runs, so turn 2 opens and claims it without a // second model call: the Activation is mid-turn when the drain cancels it. - await followup(ctx, parent, started.childId, message('second task')) + await queuePrompt(ctx, parent, started.childId, message('second task')) releaseFirst.resolve(undefined) await atCheckpoint.promise const drained = drainManager(ctx) @@ -1953,7 +1969,7 @@ describe('continuable settlement delivery', () => { // Context maintenance folds into `idle` and defers waking work, so this // delivery is accepted with no turn to claim it. const maintaining = child.runMaintenance(async () => { await releaseMaintenance.promise }) - await followup(ctx, parent, started.childId, message('never runs')) + await queuePrompt(ctx, parent, started.childId, message('never runs')) const drained = drainManager(ctx) releaseMaintenance.resolve(undefined) releaseGrandchild.resolve(undefined) @@ -2258,7 +2274,7 @@ describe('continuable lifecycle observation', () => { await vi.waitFor(() => { expect(ends).toHaveLength(1) }) // A cold resume is a NEW epoch with its own pair. - await followup(ctx, parent, started.childId, message('again')) + await queuePrompt(ctx, parent, started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) @@ -2314,7 +2330,7 @@ describe('continuable public API', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, parent, started.childId, message('aborted'), controller.signal)) + await expect(queuePrompt(ctx, parent, started.childId, message('aborted'), controller.signal)) .rejects.toThrow() const loaded = await ctx.sessionPersistence.load(started.childId) @@ -2332,7 +2348,7 @@ describe('continuable public API', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const controller = new AbortController() - await followup(ctx, parent, started.childId, message('survives'), controller.signal) + await queuePrompt(ctx, parent, started.childId, message('survives'), controller.signal) // After acceptance the manager owns the Activation independently. controller.abort('caller gave up') @@ -2361,7 +2377,7 @@ describe('continuable errors', () => { }).continuations manager.activations.delete(started.childId) - await expect(followup(ctx, parent, started.childId, message('hello'))) + await expect(queuePrompt(ctx, parent, started.childId, message('hello'))) .rejects.toThrow(SubagentError) expect(ctx.agents.get(started.childId)).toBe(child) hold.resolve(undefined) @@ -2378,7 +2394,7 @@ describe('continuable errors', () => { // A stale parent reference: same id, not the exact live entry. const stale = { ...parent, id: parent.id } as unknown as Agent - await expect(followup(ctx, stale, started.childId, message('stale'))) + await expect(queuePrompt(ctx, stale, started.childId, message('stale'))) .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) void child }) @@ -2493,7 +2509,7 @@ describe('continuable errors', () => { }) // The resumed Activation runs on the declared route, not the parent's. - await followup(ctx, parent, started.childId, message('again')) + await queuePrompt(ctx, parent, started.childId, message('again')) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)?.options).toMatchObject({ model: 'child-model', @@ -2547,8 +2563,8 @@ describe('SubagentRuntime.interrupt', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - await followup(ctx, parent, started.childId, message('parked B')) - await followup(ctx, parent, started.childId, message('parked C')) + await queuePrompt(ctx, parent, started.childId, message('parked B')) + await queuePrompt(ctx, parent, started.childId, message('parked C')) const cancelSpy = vi.spyOn(child, 'cancel') ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) @@ -2567,7 +2583,7 @@ describe('SubagentRuntime.interrupt', () => { // Only an explicit waking send restores the driver; the parked items then // run before it in the existing FIFO order. - await followup(ctx, parent, started.childId, message('waking D')) + await queuePrompt(ctx, parent, started.childId, message('waking D')) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D']) diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index dd04d549b1..76b9c0472b 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -12,6 +12,7 @@ import SubagentRuntime, { type SubagentListEntry, type SubagentPromptRequestId, } from '@deepseek-ai/dsh-subagent' +import { queueSubagentPrompt, type HostPromptQueue } from '@deepseek-ai/dsh-subagent/internal' const PARENT = SessionId('parent') const CHILD = SessionId('child') @@ -30,6 +31,11 @@ async function bench(live?: Record) { return { ctx, subagents: ctx.subagents } } +/** Spy on the private human-Queue adapter without widening the public service. */ +function promptDelivery(subagents: SubagentRuntime) { + return vi.spyOn(subagents as unknown as HostPromptQueue, queueSubagentPrompt) +} + function childRow(id: SessionId, activity: 'running' | 'inactive'): SubagentListEntry { return { kind: 'child', id, mode: 'continuable', label: 'worker', activity, hasChildren: false } } @@ -142,7 +148,7 @@ describe('subagent catalog Remote', () => { describe('subagent prompt Remote', () => { it('rejects empty parent and child ids before delivery', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - const followup = vi.spyOn(subagents, 'followup') + const delivery = promptDelivery(subagents) const cases: readonly { readonly field: 'parentSessionId' | 'childSessionId' @@ -155,47 +161,45 @@ describe('subagent prompt Remote', () => { await expect(subagents.prompt(request, signal)) .rejects.toMatchObject({ failure: emptyIdFailure('subagent.prompt', field) }) } - expect(followup).not.toHaveBeenCalled() + expect(delivery).not.toHaveBeenCalled() }) it('forwards non-text content blocks without narrowing them', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-content' as MessageId) + const delivery = promptDelivery(subagents).mockResolvedValue('m-content' as MessageId) const content = [{ type: 'reasoning' as const, text: 'retain this block' }] await expect(subagents.prompt({ ...promptRequest(), content }, signal)) .resolves.toEqual({ messageId: 'm-content' }) - expect(followup.mock.calls[0]?.[2]).toEqual(content) + expect(delivery.mock.calls[0]?.[2]).toEqual(content) }) it('delivers the content under the caller-minted identity and canonical browser zone', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-1' as MessageId) + const delivery = promptDelivery(subagents).mockResolvedValue('m-1' as MessageId) await expect(subagents.prompt(promptRequest('Asia/Shanghai'), signal)) .resolves.toEqual({ messageId: 'm-1' }) - expect(followup).toHaveBeenCalledWith( + expect(delivery).toHaveBeenCalledWith( { status: 'idle' }, CHILD, [{ type: 'text', text: 'continue' }], - { - source: { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' }, - signal, - }, + { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' }, + signal, ) }) it('omits the zone from the durable source when the browser reported none', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-2' as MessageId) + const delivery = promptDelivery(subagents).mockResolvedValue('m-2' as MessageId) await expect(subagents.prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-2' }) - expect(followup.mock.calls[0]?.[3].source).toEqual({ kind: 'user', rpcId: REQUEST_ID }) + expect(delivery.mock.calls[0]?.[3]).toEqual({ kind: 'user', rpcId: REQUEST_ID }) }) it('accepts UTC and rejects an empty, untrimmed, malformed, or unknown zone', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - vi.spyOn(subagents, 'followup').mockResolvedValue('m-3' as MessageId) + promptDelivery(subagents).mockResolvedValue('m-3' as MessageId) await expect(subagents.prompt(promptRequest('UTC'), signal)).resolves.toEqual({ messageId: 'm-3' }) for (const zone of ['', ' UTC', 'Shanghai', 'Nowhere/Nowhere']) { @@ -207,17 +211,17 @@ describe('subagent prompt Remote', () => { it('refuses delivery when the exact parent Agent is not live', async () => { const { subagents } = await bench() - const followup = vi.spyOn(subagents, 'followup') + const delivery = promptDelivery(subagents) await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({ failure: { code: 'subagent-parent-unavailable', details: { parentSessionId: PARENT } }, }) - expect(followup).not.toHaveBeenCalled() + expect(delivery).not.toHaveBeenCalled() }) it('maps each admission failure onto its stable code and hides the rest', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - const followup = vi.spyOn(subagents, 'followup') + const delivery = promptDelivery(subagents) const cases: readonly [string, string][] = [ ['NOT_RESUMABLE', 'subagent-not-resumable'], ['UNAUTHORIZED', 'subagent-unauthorized'], @@ -226,12 +230,12 @@ describe('subagent prompt Remote', () => { ['NO_PROVIDER', 'internal'], ] for (const [thrown, code] of cases) { - followup.mockRejectedValue(new SubagentError('refused', thrown)) + delivery.mockRejectedValue(new SubagentError('refused', thrown)) await expect(subagents.prompt(promptRequest(), signal)) .rejects.toMatchObject({ failure: { code } }) } - followup.mockRejectedValue(new Error('inbox exploded')) + delivery.mockRejectedValue(new Error('inbox exploded')) await expect(subagents.prompt(promptRequest(), signal)) .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent prompt failed' } }) }) @@ -239,7 +243,7 @@ describe('subagent prompt Remote', () => { it('answers a caller-cancelled delivery as cancelled rather than a failure', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) const aborted = new AbortController() - vi.spyOn(subagents, 'followup').mockImplementation(() => { + promptDelivery(subagents).mockImplementation(() => { aborted.abort() return Promise.reject(new SubagentError('gone', 'NOT_RESUMABLE')) }) @@ -250,7 +254,7 @@ describe('subagent prompt Remote', () => { it('preserves a cancellation reported by the continuation operation', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) - vi.spyOn(subagents, 'followup') + promptDelivery(subagents) .mockRejectedValue(new SubagentError('stopped', 'CANCELLED')) await expect(subagents.prompt(promptRequest(), signal)) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 6a93f2fdef..b147c061ae 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -153,11 +153,11 @@ describe('SubagentRuntime', () => { request: baseRequest(), signal: new AbortController().signal, })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) - await expect(subagents.followup( + await expect(subagents.sendMessage( fakeParent(), SessionId('child'), [{ type: 'text', text: 'hello' }], - { source: { kind: 'user' }, signal: new AbortController().signal }, + { signal: new AbortController().signal }, )).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) }) diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index 15618d9346..358311bb9c 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/subagent/tool-subagent-control/README.md -README.md: 18cdd9d22be3ce30057bcaa518f3795fcc89f48d -README.zh.md: 46f31d7dcba3d4e05ddf212dda087fd3a671b69e +README.md: a1a4f45e982db5d936ff6d6d79e3c7deffc414b1 +README.zh.md: da763aafcfae0a345adb412c945a348d74eb764c diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 18cdd9d22b..a1a4f45e98 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-subagent-control` adds the global control tools for continuable children: `send_message` delivers a follow-up message that becomes the child's next turn, `interrupt_agent` stops a child's current turn while keeping its queue and descendants intact, and `list_agents` (from the separately loadable `list-agents` plugin) lists continuable children by durable id and label. The tools are global, so any number of delegation tools never duplicates them. These tools cover only the parent-to-child direction; the child-to-parent direction belongs to the independently installed `dsh-tool-subagent-report`. No tool's presence decides whether a delegation tool starts continuable work. +`dsh-tool-subagent-control` adds the global control tools for continuable children: `send_message` steers a direct child at its nearest step, `interrupt_agent` stops a child's current turn while keeping its inbox and descendants intact, and `list_agents` (from the separately loadable `list-agents` plugin) lists continuable children by durable id and label. The tools are global, so any number of delegation tools never duplicates them. The temporary child-scoped `report` tool uses the same service operation in the reverse direction. No tool's presence decides whether a delegation tool starts continuable work. ## Table of Contents @@ -68,11 +68,11 @@ This section explains what the tools delegate to the subagent service; the obser ### Design concept -Thin adapters over `ctx.subagents.followup()`, `interrupt()`, and the list projections; the tools perform no lifecycle routing. Residency, cold resume, and interrupt authorization belong to the service, and the tools pass the exact live calling agent (`exec.agent`) as the authority the service verifies against the target's recorded lineage. +Thin adapters over `ctx.subagents.sendMessage()`, `interrupt()`, and the list projections; the tools perform no lifecycle routing. Residency, cold resume, and authorization belong to the service, and the tools pass the exact live calling agent (`exec.agent`) as both sender and authority. ### Delivery and signal ownership -The tool forwards its execution signal, which owns admission only until inbox acceptance. Once the child accepts a message, the accepted turn cannot be cancelled through this tool. Every message is recorded with the coordinator source `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. +The tool forwards its execution signal, which owns admission only until inbox acceptance. Once the child accepts a message, it cannot be cancelled through this tool. Every message is framed as `Agent sent a message:` and recorded with `{ kind: 'agent-message', form: 'relay', senderSessionId: parent.id }`; the service derives that attribution and never treats it as authority. ### Listing projection @@ -97,7 +97,7 @@ Read these pages when the package-level contract is not enough; they move from t - [Subagent subsystem](../../../docs/subsystems/subagent.md) — continuable children, activations, inbox, interrupt, and follow-up authority. - [dsh-tool-subagent](../tool-subagent/README.md) — the delegation tool that starts continuable children. -- [dsh-tool-subagent-report](../tool-subagent-report/README.md) — the child-to-parent report channel. +- [dsh-tool-subagent-report](../tool-subagent-report/README.md) — the temporary child-to-parent adapter over the same service operation. - [Generated tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control) — the three tool schemas. ----- diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 46f31d7dcb..da763aafcf 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-tool-subagent-control` 为可继续子级添加全局控制工具:`send_message` 投递一条成为子级下一轮次的后续消息,`interrupt_agent` 停止子级当前轮次但保留其队列与后代,`list_agents`(来自可单独加载的 `list-agents` 插件)按持久化 id 与标签列出可继续子级。这些工具是全局的,因此任意数量的委派工具都不会产生重复。这些工具只覆盖父到子方向;子到父方向属于独立安装的 `dsh-tool-subagent-report`。是否加载这些工具不会决定委派工具是否启动可继续工作。 +`dsh-tool-subagent-control` 为可继续子级添加全局控制工具:`send_message` 在直接 child 最近的 step 进行 steer,`interrupt_agent` 停止 child 当前轮次但保留其 inbox 与后代,`list_agents`(来自可单独加载的 `list-agents` 插件)按持久化 id 与标签列出可继续 child。这些工具是全局的,因此任意数量的委派工具都不会产生重复。临时的 child 作用域 `report` 工具反向使用同一个服务操作。是否加载这些工具不会决定委派工具是否启动可继续工作。 ## 目录 @@ -68,11 +68,11 @@ kind: "package-reference" ### 设计理念 -`ctx.subagents.followup()`、`interrupt()` 与列表投影之上的轻量适配器;工具不执行任何生命周期路由。驻留、冷恢复与中断授权归服务所有,工具把确切在线的调用 agent(`exec.agent`)作为服务对照目标已记录 lineage 校验的权限凭据传入。 +`ctx.subagents.sendMessage()`、`interrupt()` 与列表投影之上的轻量适配器;工具不执行任何生命周期路由。驻留、冷恢复与授权归服务所有,工具把确切在线的调用 Agent(`exec.agent`)同时作为 sender 与权限凭据传入。 ### 投递与信号所有权 -工具转发其执行信号,该信号只在 inbox 接受之前掌管准入。子级一旦接受消息,已接受的轮次便无法再通过本工具取消。每条消息都记录协调者来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。 +工具转发其执行信号,该信号只在 inbox 接受之前掌管准入。child 一旦接受消息,该消息便无法再通过本工具取消。每条消息都以 `Agent sent a message:` 作为前缀,并记录 `{ kind: 'agent-message', form: 'relay', senderSessionId: parent.id }`;该来源信息由服务推导,且绝不被视为权限。 ### 列表投影 @@ -97,7 +97,7 @@ kind: "package-reference" - [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——可继续子级、Activation、inbox、中断与后续消息权限。 - [dsh-tool-subagent](../tool-subagent/README.zh.md)——启动可继续子级的委派工具。 -- [dsh-tool-subagent-report](../tool-subagent-report/README.zh.md)——子到父的上报通道。 +- [dsh-tool-subagent-report](../tool-subagent-report/README.zh.md)——同一服务操作之上的临时 child 到 parent 适配器。 - [生成工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent-control)——三个工具的 schema。 ----- diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 1a83149f4f..82eaeaa4ce 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,6 +1,6 @@ /** * The globally named `send_message` and `interrupt_agent` tools: thin - * model-facing adapters over `ctx.subagents.followup()` and + * model-facing adapters over `ctx.subagents.sendMessage()` and * `ctx.subagents.interrupt()`. They perform no lifecycle routing of their own — * residency, cold resume, and interrupt authorization belong to the subagent * service — and they live apart from the provider-bound @@ -26,11 +26,10 @@ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ 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.', + 'Send a message to a background subagent by its subagent id, continuing the same conversation. If it ' + + 'is still working, the message steers its nearest step; if it is idle, the message starts a turn. ' + + 'This call returns no answer from the subagent — only confirmation that the message was delivered. ' + + 'A failure means the message was NOT delivered.', parameters: { subagent_id: { type: 'string', @@ -53,24 +52,20 @@ export function apply(ctx: Context): void { }, render: (args, _value) => [{ type: 'text', - text: `message queued as the next turn for subagent ${args.subagent_id}`, + text: `message delivered to subagent ${args.subagent_id}`, }], }, async execute(args, exec) { - const parent = exec.agent - if (!parent) { - // Parent authority requires an exact live calling agent. + const sender = exec.agent + if (!sender) { throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const messageId = await ctx.subagents.followup( - parent, + const messageId = await ctx.subagents.sendMessage( + sender, SessionId(args.subagent_id), message, - { - source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id }, - signal: exec.signal, - }, + { signal: exec.signal }, ) return { messageId } }, diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index f0b835fe1c..2766d4997b 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -111,11 +111,10 @@ describe('dsh-tool-subagent-control', () => { // The continuable path has no Task, so the schema must not promise one. expect(schemas[0]!.description).not.toContain('job_output') expect(schemas[0]!.description).not.toContain('job id') - // Follow-up ordering is model-visible: it cannot redirect the open turn. - expect(schemas[0]!.description).toContain('next turn') + expect(schemas[0]!.description).toContain('nearest step') }) - it('cold-resumes a settled child and reports the queued next turn', async () => { + it('cold-resumes a settled child and reports delivery', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) const started = await ctx.subagents.startContinuable({ provider: 'spawn', @@ -131,20 +130,24 @@ describe('dsh-tool-subagent-control', () => { }, parent) expect(result.isError).toBe(false) - expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`) + expect(text(result)).toBe(`message delivered to subagent ${started.childId}`) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) const followUp = loaded.events.findLast(event => event.type === 'user/message') // The durable message source records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ - kind: 'coordinator', + kind: 'agent-message', form: 'relay', senderSessionId: parent.id, }) + expect(followUp?.type === 'user/message' && followUp.data.content).toEqual([ + { type: 'text', text: `Agent ${parent.id} sent a message:` }, + { type: 'text', text: 'and then?' }, + ]) }) - it('queues behind an open turn instead of joining it', async () => { + it('steers the nearest step of an open turn', async () => { const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) const started = await ctx.subagents.startContinuable({ provider: 'spawn', @@ -165,8 +168,11 @@ describe('dsh-tool-subagent-control', () => { const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) - // A follow-up is its own later turn, never steering inside the first one. - expect(prompts).toEqual(['long work', 'also consider Y']) + expect(prompts).toEqual([ + 'long work', + `Agent ${parent.id} sent a message:`, + 'also consider Y', + ]) }) it('reports a delivery failure as an errored, not-delivered result', async () => { @@ -267,9 +273,9 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true }) releaseFirst.resolve(undefined) await child.whenIdle() - // Parked, not resumed: the queued follow-up waits for a waking send. + // Parked, not resumed: the steering waits for another waking send. expect(adapter.requests).toHaveLength(1) - expect(child.inbox.nextTurn).toHaveLength(1) + expect(child.inbox.nextStep).toHaveLength(1) const waking = await callTool(ctx, 'send_message', { subagent_id: started.childId, @@ -281,7 +287,13 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) - expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up']) + expect(prompts).toEqual([ + 'long work', + `Agent ${parent.id} sent a message:`, + 'parked follow-up', + `Agent ${parent.id} sent a message:`, + 'wake up', + ]) }) it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 8ebd09d64c..dee83584b6 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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/subagent/tool-subagent-report/README.md -README.md: 24dac02c11925de9d99264a7ff672f556701faf2 -README.zh.md: 089919d96677528946a000453ff9abdad174e631 +README.md: 19fc7202da4d36b5e32c622b526c9366d498abfd +README.zh.md: e43357cfca05f0603a373f2f36a26a8a6859f5f3 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index 24dac02c11..19fc7202da 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-subagent-report` gives every continuable in-process child a return channel to the agent that started it: it installs a child-scoped `report` tool plus the prompt guidance that tells the child to use it. The tool and its guidance exist only inside those children — roots, one-shot subagents, remote providers, and sibling scopes never see them. Accepted reports reach the parent as ordinary parent messages, framed as `Background subagent reported:`. Continuable mode depends on neither this package nor the control package; this one owns only the child-to-parent direction. +`dsh-tool-subagent-report` gives every continuable in-process child a temporary child-scoped adapter over the adjacent-Agent messaging service: it installs a `report` tool plus prompt guidance telling the child to use it. Roots, one-shot subagents, remote providers, and sibling scopes never see either registration. Accepted reports reach the direct parent through fixed Steer scheduling and the same framing and provenance as parent-to-child messages. Continuable mode depends on neither this package nor the control package. ## Table of Contents @@ -41,11 +41,7 @@ Load the subagent service, a backend, the delegation tool in `continuable` mode, - name: '@deepseek-ai/dsh-tool-subagent-report' ``` -| Field | Default | Meaning | -|---|---|---| -| `reportDelivery` | `next-step` | Parent scheduling for accepted reports: `next-step` wakes the parent at its nearest step boundary; `quiet` adds the same context without waking it | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-subagent-report) is the exhaustive source for every accepted field and its JSDoc. +The package takes no configuration. ### What the child gets @@ -53,7 +49,7 @@ Each continuable child gets a `report` tool whose only parameter is `output` — ### What the parent sees -An accepted report becomes one user-role parent message framed as `Background subagent reported:` followed by the child's exact output, with a durable source naming the child. `next-step` delivery wakes an idle parent or joins a running parent's nearest step boundary; `quiet` delivery adds the same context without waking the parent. The tool takes no recipient: the service derives the sole recipient from the child's durable `parentSession`. +An accepted report becomes one user-role parent message framed as `Agent sent a message:` followed by the child's exact output, with a durable `agent-message` source naming the child. Fixed Steer scheduling starts a turn for an idle parent or joins a running parent's nearest step boundary. The tool takes no recipient: it derives the sole recipient from the child's durable `parentSession` and delegates authorization and delivery to `ctx.subagents.sendMessage()`. ### Scope and direction @@ -75,17 +71,17 @@ The package registers a continuable-child setup contribution rather than a globa ### Delivery scheduling -`next-step` uses `parent.steer()`: a running parent receives the report at its nearest safe step boundary, an idle parent starts a turn, and reports accepted in sequence share the next-step FIFO. `quiet` uses `parent.inject()`, adding the same next-step context without waking a parked parent. Both are deployment policy: the model-facing schema cannot select or override delivery per call. +The service always uses `parent.steer()`: a running parent receives the report at its nearest step boundary, an idle parent starts a turn, and reports accepted in sequence share the next-step FIFO. The model-facing schema cannot select or override scheduling. ### Exported contribution -`installReportTool(childCtx, ctx, delivery)` installs the tool and guidance into a minted child scope and returns one disposer revoking both. The generated tool catalog uses this path because the global registry cannot expose a scope-local schema; production composition still enters through `apply()`. +`installReportTool(childCtx, ctx)` installs the tool and guidance into a minted child scope and returns one disposer revoking both. The generated tool catalog uses this path because the global registry cannot expose a scope-local schema; production composition still enters through `apply()`. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Continuable-child setup: `installReportTool`, `Config`, delivery resolution | +| [`src/index.ts`](src/index.ts) | Continuable-child setup and `installReportTool` adapter | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion | @@ -97,11 +93,10 @@ The package registers a continuable-child setup contribution rather than a globa Read these pages when the package-level contract is not enough; they move from the report channel to the continuation service behind it and the parent-facing tools. -- [Subagent subsystem](../../../docs/subsystems/subagent.md) — continuable children, activations, and the `reportFrom`/`reportDelivery` contract. +- [Subagent subsystem](../../../docs/subsystems/subagent.md) — continuable children, activations, and the `sendMessage` contract. - [dsh-tool-subagent-control](../tool-subagent-control/README.md) — the parent-to-child control tools. - [dsh-tool-subagent](../tool-subagent/README.md) — the delegation tool that starts continuable children. - [Generated tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report) — the `report` schema. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-subagent-report) — every accepted config field. ----- @@ -126,11 +121,11 @@ Prefix-stable within a child; neither the schema nor the section changes at runt #### What the model sees -`report accepted by the agent that started you as message ` on acceptance; the canonical output carries the stable `messageId`. A failure from an unauthorized sender, an unavailable parent, or a closing lifecycle is an errored result. The description says a failed call may still have arrived, because a later `tools/post-execute` failure can replace the result after `reportFrom()` accepted the message. +`report accepted by the agent that started you as message ` on acceptance; the canonical output carries the stable `messageId`. A failure from an unauthorized sender, an unavailable parent, or a closing lifecycle is an errored result. Delivery acceptance still precedes later tool-result hooks, which are outside this package. #### Token effect -One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: next-step delivery joins the next request in an open parent turn or starts a turn for an idle parent, while quiet delivery waits for another input to wake the parent. +One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: delivery joins the next request in an open parent turn or starts a turn for an idle parent. #### KV Cache effect @@ -140,7 +135,7 @@ Append-only in the child. In the parent, the framed report follows existing hist #### What the model sees -One user-role parent message framed as `Background subagent reported:` followed by the child's exact `output`, with a durable source `{ kind: 'subagent-report', senderSessionId: }` that names the child. +One user-role parent message framed as `Agent sent a message:` followed by the child's exact `output`, with a durable source `{ kind: 'agent-message', form: 'relay', senderSessionId: }` that names the child. #### Token effect @@ -148,7 +143,7 @@ The child's complete `output` plus the one-line frame, uncapped by this package. #### KV Cache effect -Append-only; the report follows the parent's reusable request prefix. Next-step delivery wakes the parent and may extend its open turn, while quiet delivery does not wake it. +Append-only; the report follows the parent's reusable request prefix. Steer wakes an idle parent and may extend an open turn. ## Known Limitations and Deferred Work @@ -159,10 +154,9 @@ These limits define what an accepted report does and does not guarantee; they ar - **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary. - **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report. -- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. - **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` and its guidance only on that child's next Activation, while removing the package revokes both from resident children immediately. - **Nested reporting reaches exactly one edge upward** — a grandchild reports to its direct child parent, never to the top-level coordinator, which must explicitly report a derived update later. -- **No rate limiting** — the default `next-step` mode can amplify model work when nested children report frequently, although reports waiting together share one step; a deployment that accepts unread reports over that amplification selects `quiet`. +- **No rate limiting** — frequent nested reports can amplify model work, although reports waiting together share one step. ### Dev Note diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 089919d966..e43357cfca 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-tool-subagent-report` 为每个可继续的进程内子级提供一条返回通道,指向启动它的 agent(智能体):它安装子级作用域的 `report` 工具,以及指示子级使用该工具的提示词指导。工具及其指导只存在于这些子级内部——根 agent、一次性 subagent、远程提供方与同级作用域永远看不到它们。被接受的报告会以普通父级消息到达父级,前缀为 `Background subagent reported:`。可继续模式不依赖本包,也不依赖控制包;本包只负责子到父方向。 +`dsh-tool-subagent-report` 为每个可继续的进程内 child 提供一个临时的 child 作用域适配器,连接相邻 Agent 消息服务:它安装 `report` 工具及指示 child 使用该工具的提示词指导。根 Agent、一次性 subagent、远程提供方与 sibling 作用域永远看不到这两个注册项。被接受的报告通过固定 Steer 调度到达直接 parent,并与 parent 到 child 的消息使用相同前缀和来源信息。可继续模式不依赖本包,也不依赖控制包。 ## 目录 @@ -41,11 +41,7 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-tool-subagent-report' ``` -| 字段 | 默认值 | 含义 | -|---|---|---| -| `reportDelivery` | `next-step` | 已接受报告的父级调度:`next-step` 在最近 step 边界唤醒父级;`quiet` 添加相同上下文但不唤醒 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-subagent-report)是每个受支持字段及其 JSDoc 的穷尽式真源。 +本包不接收任何配置。 ### 子级获得什么 @@ -53,7 +49,7 @@ kind: "package-reference" ### 父级看到什么 -被接受的报告会成为一条用户角色的父级消息,以 `Background subagent reported:` 开头,后接子级未经改动的输出,并带有指明子级的持久化来源。`next-step` 投递会唤醒空闲父级,或加入运行中父级最近的 step 边界;`quiet` 投递添加相同上下文但不唤醒父级。工具不接受接收方参数:服务根据子级持久化的 `parentSession` 推导唯一接收方。 +被接受的报告会成为一条用户角色的 parent 消息,以 `Agent sent a message:` 开头,后接 child 未经改动的输出,并带有指明 child 的持久化 `agent-message` 来源。固定 Steer 调度会为空闲 parent 启动一个轮次,或加入运行中 parent 最近的 step 边界。工具不接受接收方参数:它根据 child 持久化的 `parentSession` 推导唯一接收方,并把授权与投递交给 `ctx.subagents.sendMessage()`。 ### 作用域与方向 @@ -75,17 +71,17 @@ kind: "package-reference" ### 投递调度 -`next-step` 使用 `parent.steer()`:运行中的父级在最近的安全 step 边界接收报告,空闲父级启动一个轮次,按顺序接受的报告共享 next-step FIFO。`quiet` 使用 `parent.inject()`,添加相同的 next-step 上下文但不唤醒停驻的父级。两者都是部署策略:面向模型的 schema 不能在单次调用中选择或覆盖投递方式。 +服务始终使用 `parent.steer()`:运行中的 parent 在最近的 step 边界接收报告,空闲 parent 启动一个轮次,按顺序接受的报告共享 next-step FIFO。面向模型的 schema 不能选择或覆盖调度方式。 ### 导出的贡献 -`installReportTool(childCtx, ctx, delivery)` 把工具及其指导安装到新创建的子级作用域中,并返回同时撤销两者的唯一 disposer。生成工具目录使用这条路径,因为全局注册表无法公开作用域局部 schema;生产组合仍通过 `apply()` 进入。 +`installReportTool(childCtx, ctx)` 把工具及其指导安装到新创建的 child 作用域中,并返回同时撤销两者的唯一 disposer。生成工具目录使用这条路径,因为全局注册表无法公开作用域局部 schema;生产组合仍通过 `apply()` 进入。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 可继续子级设置:`installReportTool`、`Config`、投递解析 | +| [`src/index.ts`](src/index.ts) | 可继续 child 设置与 `installReportTool` 适配器 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | @@ -97,11 +93,10 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面;它们从上报通道进入其背后的继续执行服务与面向父级的工具。 -- [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——可继续子级、Activation 与 `reportFrom`/`reportDelivery` 约定。 +- [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——可继续 child、Activation 与 `sendMessage` 约定。 - [dsh-tool-subagent-control](../tool-subagent-control/README.zh.md)——父到子的控制工具。 - [dsh-tool-subagent](../tool-subagent/README.zh.md)——启动可继续子级的委派工具。 - [生成工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent-report)——`report` 的 schema。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-subagent-report)——每个受支持配置字段。 ----- @@ -126,11 +121,11 @@ kind: "package-reference" #### 模型看到什么 -接受时返回 `report accepted by the agent that started you as message `;规范输出携带稳定的 `messageId`。发送方未授权、父级不可用或生命周期正在关闭时,会返回出错结果。描述中会说明,失败的调用仍可能已经送达,因为 `reportFrom()` 接受消息后,后续 `tools/post-execute` 失败可能替换工具结果。 +接受时返回 `report accepted by the agent that started you as message `;规范输出携带稳定的 `messageId`。sender 未授权、parent 不可用或生命周期正在关闭时,会返回出错结果。投递接受之后仍可能运行后续工具结果 hook,它们不归本包所有。 #### Token 影响 -每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:next-step 投递会加入父级已打开轮次的下一次请求,或为空闲父级启动一个轮次;静默投递则等待其他输入唤醒父级。 +每次调用都会在执行上报的 child 中产生一条简短确认消息。parent 还会为上报内容支付 token 成本:投递会加入 parent 已打开轮次的下一次请求,或为空闲 parent 启动一个轮次。 #### KV Cache 影响 @@ -140,7 +135,7 @@ kind: "package-reference" #### 模型看到什么 -一条用户角色的父级消息,以 `Background subagent reported:` 开头,后接子级未经改动的 `output`,并带有指明该子级的持久化来源 `{ kind: 'subagent-report', senderSessionId: }`。 +一条用户角色的 parent 消息,以 `Agent sent a message:` 开头,后接 child 未经改动的 `output`,并带有指明该 child 的持久化来源 `{ kind: 'agent-message', form: 'relay', senderSessionId: }`。 #### Token 影响 @@ -148,7 +143,7 @@ kind: "package-reference" #### KV Cache 影响 -仅追加;报告位于父级可复用请求前缀之后。next-step 投递会唤醒父级,并可能延长其已打开的轮次;静默投递则不会唤醒父级。 +仅追加;报告位于 parent 可复用请求前缀之后。Steer 会唤醒空闲 parent,并可能延长已打开的轮次。 ## 已知限制与延期工作 @@ -159,10 +154,9 @@ kind: "package-reference" - **父级可能在宿主启动 dispose 后继续接受报告**——`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript(文本记录),但该父级不会在本进程中处理它。对于由继续执行管理器拥有的父级,管理器的准入边界会在整片森林拆卸期间拒绝该上报。 - **接受弱于持久投递**——没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 -- **暂存的静默报告无法立即重建**——接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级会话才能重建带前缀的内容。 - **授权须等到下一个 Activation,撤销则立即生效**——子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report` 及其指导;移除本包则会立即从驻留子级撤销两者。 - **嵌套上报只向上到达一条直接边**——孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。 -- **没有速率限制**——嵌套子级频繁上报时,默认的 `next-step` 模式会放大模型工作量,但一起等待的报告会共享一个 step;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。 +- **没有速率限制**——嵌套 child 频繁上报会放大模型工作量,但一起等待的报告会共享一个 step。 ### 开发备注 diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index a0ff7ecede..5e2b8ef664 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -39,9 +39,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index fb484e8a2d..3bd18b0095 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -7,10 +7,9 @@ */ import type { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-subagent' import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -23,33 +22,17 @@ export const inject = ['subagents', 'tools', 'systemPrompt'] /** Guidance order after every per-tool section a continuable child can carry. */ const REPORT_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.TOOL_REPORT -/** Config: how accepted reports are scheduled on the parent. */ -export interface Config { - /** - * Parent scheduling (default `next-step`). `next-step` wakes the parent and - * enters at its nearest step boundary; `quiet` adds the same context without - * waking, so a parked parent waits for another waking input. - */ - reportDelivery?: SubagentReportDelivery -} - -export const Config: z = z.object({ - reportDelivery: z.union(['quiet', 'next-step'] as const).default('next-step'), -}) - /** * Install `report` and its usage guidance into one continuable child's scope. * Both registrations are owned by that scope and are therefore invisible to the * child's parent and siblings. * @param childCtx - child-scoped context receiving the tool and the guidance. * @param ctx - service context used for delivery. - * @param delivery - resolved deployment scheduling policy. * @returns disposer that attempts both child registrations before reporting cleanup failures. */ export function installReportTool( childCtx: Context, ctx: Context, - delivery: SubagentReportDelivery, ): () => void { const disposeSection = childCtx.systemPrompt.section({ name: 'tool:report', @@ -69,8 +52,9 @@ export function installReportTool( + 'self-contained final result, and earlier for progress or findings that change what that agent does ' + 'next. That agent shares your workspace but does not automatically receive your transcript, tool ' + 'output, or reasoning, so finishing your work is not itself a result. Reporting does not end your ' - + 'turn or finish your work, and only your direct parent receives it. A failed call may still have ' - + 'arrived, so do not blindly repeat it.', + + 'turn or finish your work, and only your direct parent receives it. If that agent is working, the ' + + 'report steers its nearest step; otherwise it starts a turn. A failed call may still have arrived ' + + 'if a later tool-result hook failed, so do not blindly repeat it.', parameters: { output: { type: 'string', @@ -95,10 +79,10 @@ export function installReportTool( const content: ContentBlock[] = [{ type: 'text', text: args.output }] // Scope-local resolution guarantees an Agent. The service still verifies // its exact live Activation identity at the authority boundary. - const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, { - delivery, - signal: exec.signal, - }) + const child = exec.agent as Agent + const parentId = child.session.header.parentSession + if (parentId === undefined) throw new Error('report requires a direct parent Agent') + const messageId = await ctx.subagents.sendMessage(child, parentId, content, { signal: exec.signal }) return { messageId } }, })) @@ -131,12 +115,7 @@ export function installReportTool( /** * Register the continuable-child contribution. * @param ctx - context carrying tools, the system prompt, and the subagent service. - * @param config - deployment scheduling policy. */ -export function apply(ctx: Context, config: Config = {}): void { - // Config() applies the schema default at runtime; the schemastery return - // type keeps the input's optional shape, so assert the resolved one. - const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery } - ctx.subagents.registerContinuableSetup(childCtx => - installReportTool(childCtx, ctx, reportDelivery)) +export function apply(ctx: Context): void { + ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx)) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index e8bb53925d..74b6f5bd64 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -62,7 +62,7 @@ afterEach(async () => { }) /** Boot the real continuation graph with optional report installation. */ -async function setup(options: { load?: boolean; config?: tool.Config } = {}) { +async function setup(options: { load?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-')) @@ -72,7 +72,7 @@ async function setup(options: { load?: boolean; config?: tool.Config } = {}) { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) const fiber = options.load === false ? undefined - : await ctx.plugin(tool, options.config ?? { reportDelivery: 'quiet' }) + : await ctx.plugin(tool) const adapter = new HeldAdapter() ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) @@ -140,7 +140,7 @@ function registerReportConflict(child: Agent): () => void { function reports(agent: Agent): { id: string; text: string; sender: string }[] { const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...visible, ...agent.inbox.nextStep].flatMap((message) => { - if (message.source.kind !== 'subagent-report') return [] + if (message.source.kind !== 'agent-message') return [] return [{ id: message.id, text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'), @@ -205,10 +205,9 @@ describe('dsh-tool-subagent-report', () => { expect(names).not.toContain('send_message') }) - it('delivers quiet reports with stable message and sender identities without waking', async () => { + it('delivers reports with stable message and sender identities through Steer', async () => { const { ctx, parent, adapter } = await setup() const { started, child } = await startChild(ctx, parent) - const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const enqueues: string[] = [] ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { @@ -224,34 +223,17 @@ describe('dsh-tool-subagent-report', () => { expect(renderedText(result)).toContain(messageId) expect(reports(parent)).toEqual([{ id: messageId, - text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`, + text: `Agent ${started.childId} sent a message:\nCHILD_FINDING`, sender: started.childId, }]) expect(enqueues).toEqual(['steering']) - expect(parent.status).toBe('idle') - expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests) - }) - - it('delivers next-step reports through waking steering', async () => { - const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) - const { child } = await startChild(ctx, parent) - const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', ({ agent, message }) => { - if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') - } - }) - - const result = await callReport(ctx, child, 'WAKE_UP') - expect(result.isError).toBe(false) - expect(enqueues).toEqual(['steering']) await vi.waitFor(() => { expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true) }) }) - it('batches repeated next-step reports in accepted order', async () => { - const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) + it('batches repeated reports in accepted order at a running parent step', async () => { + const { ctx, parent, adapter } = await setup() await startHeldParentTurn(parent, adapter) const { child } = await startChild(ctx, parent) @@ -262,7 +244,7 @@ describe('dsh-tool-subagent-report', () => { }) it('keeps a report before the child settlement in one busy-parent batch', async () => { - const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) + const { ctx, parent, adapter } = await setup() await startHeldParentTurn(parent, adapter) const { started, child } = await startChild(ctx, parent) @@ -271,7 +253,7 @@ describe('dsh-tool-subagent-report', () => { await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) expect(parent.inbox.nextStep.map(message => message.source.kind)).toEqual([ - 'subagent-report', + 'agent-message', 'subagent-settled', ]) expect(parent.inbox.nextTurn).toHaveLength(0) @@ -287,7 +269,7 @@ describe('dsh-tool-subagent-report', () => { expect(ctx.agents.get(started.childId) === undefined).toBe(true) }, { timeout: 5_000 }) expect(reports(parent).map(report => report.text)).toEqual([ - `Background subagent ${started.childId} reported:\nDURABLE_SELECTION`, + `Agent ${started.childId} sent a message:\nDURABLE_SELECTION`, ]) }) @@ -298,8 +280,8 @@ describe('dsh-tool-subagent-report', () => { expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false) expect(reports(parent)).toEqual([]) - // The intermediate parent's turn is open, so quiet context is pending in - // its inbox until that turn reaches its next safe log boundary. + // The intermediate parent's turn is open, so steering is pending in its + // inbox until that turn reaches its next step boundary. expect(reports(child)).toHaveLength(1) adapter.release() await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) }) @@ -307,8 +289,8 @@ describe('dsh-tool-subagent-report', () => { expect(reports(child)[0]?.text).toContain('FROM_GRANDCHILD') }) - it('accounts next-step reports delivered to a resident continuable parent', async () => { - const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) + it('accounts reports delivered to a resident continuable parent', async () => { + const { ctx, parent, adapter } = await setup() const { child } = await startChild(ctx, parent, 'outer task') const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task') @@ -324,12 +306,11 @@ describe('dsh-tool-subagent-report', () => { it('normalizes a direct parent send rejection', async () => { const { ctx, parent } = await setup() const { child } = await startChild(ctx, parent) - vi.spyOn(parent, 'inject').mockImplementationOnce(() => { + vi.spyOn(parent, 'steer').mockImplementationOnce(() => { throw new Error('parent closed during delivery') }) - await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'rejected' }], { - delivery: 'quiet', + await expect(ctx.subagents.sendMessage(child, parent.id, [{ type: 'text', text: 'rejected' }], { signal: testSignal, })).rejects.toMatchObject({ code: 'PARENT_UNAVAILABLE' }) expect(reports(parent)).toEqual([]) @@ -337,10 +318,9 @@ describe('dsh-tool-subagent-report', () => { it('rejects roots, forged same-id senders, absent parents, cancellation, and drain', async () => { const { ctx, parent, adapter } = await setup() - await expect(ctx.subagents.reportFrom(parent, [{ type: 'text', text: 'root' }], { - delivery: 'quiet', + await expect(ctx.subagents.sendMessage(parent, SessionId('not-a-child'), [{ type: 'text', text: 'root' }], { signal: testSignal, - })).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) const disposable = await ctx.agents.create({ sessionId: SessionId('disposable-parent'), @@ -348,8 +328,7 @@ describe('dsh-tool-subagent-report', () => { }) const { child } = await startChild(ctx, disposable.agent) const forged = { ...child } as Agent - await expect(ctx.subagents.reportFrom(forged, [{ type: 'text', text: 'forged' }], { - delivery: 'quiet', + await expect(ctx.subagents.sendMessage(forged, disposable.agent.id, [{ type: 'text', text: 'forged' }], { signal: testSignal, })).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) @@ -362,8 +341,7 @@ describe('dsh-tool-subagent-report', () => { adapter.release() const draining = ctx.subagents.drainContinuableDescendants([child]) - await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'draining' }], { - delivery: 'quiet', + await expect(ctx.subagents.sendMessage(child, disposable.agent.id, [{ type: 'text', text: 'draining' }], { signal: testSignal, })).rejects.toMatchObject({ code: 'DRAINING' }) await draining @@ -380,7 +358,7 @@ describe('dsh-tool-subagent-report', () => { expect(await sectionNames(ctx, child)).not.toContain('tool:report') expect((await callReport(ctx, child, 'revoked')).isError).toBe(true) - const late = await ctx.plugin(tool, { reportDelivery: 'quiet' }) + const late = await ctx.plugin(tool) expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report') expect(await sectionNames(ctx, child)).not.toContain('tool:report') await late.dispose() @@ -391,7 +369,7 @@ describe('dsh-tool-subagent-report', () => { const { child } = await startChild(ctx, parent) const disposeConflict = registerReportConflict(child) - expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/) + expect(() => tool.installReportTool(child.ctx, ctx)).toThrow(/already registered in this scope/) expect(await sectionNames(ctx, child)).not.toContain('tool:report') disposeConflict() }) @@ -409,7 +387,7 @@ describe('dsh-tool-subagent-report', () => { let failure: unknown try { - tool.installReportTool(child.ctx, ctx, 'quiet') + tool.installReportTool(child.ctx, ctx) } catch (error: unknown) { failure = error } @@ -427,7 +405,7 @@ describe('dsh-tool-subagent-report', () => { it('attempts both revocations and aggregates change-listener failures', async () => { const { ctx, parent } = await setup({ load: false }) const { child } = await startChild(ctx, parent) - const dispose = tool.installReportTool(child.ctx, ctx, 'quiet') + const dispose = tool.installReportTool(child.ctx, ctx) const toolFailure = new Error('tool removal listener failed') const promptFailure = new Error('prompt removal listener failed') const offTool = ctx.on('tools/change', () => { throw toolFailure }) @@ -551,19 +529,14 @@ describe('dsh-tool-subagent-report', () => { expect((await callReport(ctx, child, 'after-close')).isError).toBe(true) }) - it('keeps the namespace plugin shape and validates its default', () => { + it('keeps the namespace plugin shape', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt']) - // Next-step delivery wakes a parked parent and lets a running parent act at - // its nearest safe boundary. - expect(tool.Config({}).reportDelivery).toBe('next-step') - expect(() => tool.Config({ reportDelivery: 'wakeup' } as never)).toThrow() - expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow() }) - it('wakes the parent under the default configuration', async () => { - const { ctx, parent, adapter } = await setup({ config: {} }) + it('wakes an idle parent', async () => { + const { ctx, parent, adapter } = await setup() const { child } = await startChild(ctx, parent) const enqueues: string[] = [] ctx.on('agent/inbox/inserted', ({ agent, message }) => { diff --git a/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts b/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts index 9ebc0d6ac1..5c38967a12 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/subagent-durability-failure.ts @@ -86,10 +86,10 @@ export function apply(ctx: Context): void { // never reach the live inbox. let realChildId: string | undefined const subagents = ctx.subagents as unknown as { - followup: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise + sendMessage: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise } - const deliver = subagents.followup.bind(subagents) - subagents.followup = (authority, childId, content, options) => { + const deliver = subagents.sendMessage.bind(subagents) + subagents.sendMessage = (authority, childId, content, options) => { const mapped = childId === PLACEHOLDER_CHILD_ID && realChildId !== undefined ? SessionId(realChildId) : childId diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eb529f007..38898f2e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8879,10 +8879,6 @@ importers: version: link:../../core/tools packages/subagent/tool-subagent-report: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d9870dabc7..9ff4b6d24d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -471,19 +471,16 @@ export const LINK_MAP: Readonly> = { ContinuableSetupContribution: 'subagent.md', ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', - CoordinatorMessageSource: 'subagent.md', + AgentMessageSource: 'subagent.md', SubagentCatalog: 'subagent.md', SubagentDescendantListEntry: 'subagent.md', - SubagentFollowupOptions: 'subagent.md', + SubagentSendMessageOptions: 'subagent.md', SubagentInterruptAuthority: 'subagent.md', SubagentInterruptReceipt: 'subagent.md', SubagentListEntry: 'subagent.md', SubagentPromptReceipt: 'subagent.md', SubagentPromptRequest: 'subagent.md', SubagentProvider: 'subagent.md', - SubagentReportDelivery: 'subagent.md', - SubagentReportMessageSource: 'subagent.md', - SubagentReportOptions: 'subagent.md', SubagentRun: 'subagent.md', SubagentRuntime: 'subagent.md', SubagentStartRequest: 'subagent.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 6cfbc4d618..02e692320a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -33,7 +33,7 @@ import WebRuntime from '@deepseek-ai/dsh-web' import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-http' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubagentProvider, SubagentReportDelivery } from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents' import * as ToolSubagentReport from '@deepseek-ai/dsh-tool-subagent-report' @@ -505,9 +505,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ async mount(ctx) { await ctx.plugin(AgentRegistry) await ctx.plugin(SubagentRuntime) - const { reportDelivery } = ToolSubagentReport.Config({}) as { reportDelivery: SubagentReportDelivery } await mountCatalogChildScope(ctx, (childCtx) => { - ToolSubagentReport.installReportTool(childCtx, ctx, reportDelivery) + ToolSubagentReport.installReportTool(childCtx, ctx) }) }, scope: ctx => catalogChildScopes.get(ctx) as Agent, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b54672eb87..fe571584fe 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1303,17 +1303,7 @@ }, { "doc": "docs/subsystems/subagent.md", - "symbol": "CoordinatorMessageSource", - "source": "packages/subagent/subagent/src/continuation.ts" - }, - { - "doc": "docs/subsystems/subagent.md", - "symbol": "SubagentReportMessageSource", - "source": "packages/subagent/subagent/src/continuation.ts" - }, - { - "doc": "docs/subsystems/subagent.md", - "symbol": "SubagentReportDelivery", + "symbol": "AgentMessageSource", "source": "packages/subagent/subagent/src/continuation.ts" }, { @@ -1323,12 +1313,7 @@ }, { "doc": "docs/subsystems/subagent.md", - "symbol": "SubagentReportOptions", - "source": "packages/subagent/subagent/src/continuation.ts" - }, - { - "doc": "docs/subsystems/subagent.md", - "symbol": "SubagentFollowupOptions", + "symbol": "SubagentSendMessageOptions", "source": "packages/subagent/subagent/src/continuation.ts" }, { diff --git a/snapshots/sdk/bash-tool/tool-schemas.expected.json b/snapshots/sdk/bash-tool/tool-schemas.expected.json index e8fd1b5981..b0f83fcf2c 100644 --- a/snapshots/sdk/bash-tool/tool-schemas.expected.json +++ b/snapshots/sdk/bash-tool/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json index a3c5b2e531..7a537a42f6 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -322,7 +322,7 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. If that agent is working, the report steers its nearest step; otherwise it starts a turn. A failed call may still have arrived if a later tool-result hook failed, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { @@ -338,7 +338,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-continuable/session.1.jsonl b/snapshots/sdk/subagent-continuable/session.1.jsonl index 40d191db24..8528a2745f 100644 --- a/snapshots/sdk/subagent-continuable/session.1.jsonl +++ b/snapshots/sdk/subagent-continuable/session.1.jsonl @@ -7,8 +7,8 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"}]}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Agent {{session:1}} sent a message:"},{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":1,"inserted":[{"content":[{"type":"text","text":"Agent {{session:1}} sent a message:"},{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"}]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"},"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`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"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`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"} @@ -20,21 +20,17 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"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":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[16,20]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":2,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:1}} sent a message:"},{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:1}} sent a message:"},{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:16}}"},"surfaceOp":"append"} +{"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":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"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":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[27,31]],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","data":{"turn":2}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":2,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"{{session:1}}"},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":2,"step":1}} -{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","data":{"turn":3}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"turn/end","data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} diff --git a/snapshots/sdk/subagent-continuable/session.jsonl b/snapshots/sdk/subagent-continuable/session.jsonl index e858c9283e..e1487842c1 100644 --- a/snapshots/sdk/subagent-continuable/session.jsonl +++ b/snapshots/sdk/subagent-continuable/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"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":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[12,16]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent {{session:2}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -26,9 +26,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[22,26]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent {{session:2}}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message delivered to subagent {{session:2}}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -36,9 +36,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[32,36]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"{{session:2}}\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent {{session:2}}"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message delivered to subagent {{session:2}}"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -46,7 +46,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[42,46]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} {"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"{{message:10}}"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} @@ -56,19 +56,19 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[52,56]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"SECOND_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} failed before it finished.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:12}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"SECOND_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:12}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"SECOND_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} failed before it finished.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"SECOND_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SUBAGENT_SETTLED_NOTED"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[65,69]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json index a3c5b2e531..7a537a42f6 100644 --- a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json @@ -322,7 +322,7 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. If that agent is working, the report steers its nearest step; otherwise it starts a turn. A failed call may still have arrived if a later tool-result hook failed, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { @@ -338,7 +338,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json index b3a1813e8b..d9bfe813f3 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json index fe0882fe53..3b6817066e 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json index 3d92e885eb..d0ba721089 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json @@ -339,7 +339,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json index a3c5b2e531..7a537a42f6 100644 --- a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json @@ -322,7 +322,7 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. If that agent is working, the report steers its nearest step; otherwise it starts a turn. A failed call may still have arrived if a later tool-result hook failed, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { @@ -338,7 +338,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/subagent-report/session.1.jsonl b/snapshots/sdk/subagent-report/session.1.jsonl index cc643d3894..07f87086fd 100644 --- a/snapshots/sdk/subagent-report/session.1.jsonl +++ b/snapshots/sdk/subagent-report/session.1.jsonl @@ -18,7 +18,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"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":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[14,18]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message {{message:6}}"}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"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":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[24,28]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/subagent-report/session.jsonl b/snapshots/sdk/subagent-report/session.jsonl index 8378002d23..afcfcec16f 100644 --- a/snapshots/sdk/subagent-report/session.jsonl +++ b/snapshots/sdk/subagent-report/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"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":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[12,16]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent {{session:2}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -26,23 +26,23 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"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":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[22,26]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:6}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Agent {{session:2}} sent a message:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:6}}"}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Reported."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:2}} sent a message:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Reported."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SUBAGENT_SETTLED_NOTED"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[38,42]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"}]}} @@ -55,6 +55,6 @@ {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[[51,55]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":3,"step":1}} {"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json index a3c5b2e531..7a537a42f6 100644 --- a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json @@ -322,7 +322,7 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. If that agent is working, the report steers its nearest step; otherwise it starts a turn. A failed call may still have arrived if a later tool-result hook failed, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { @@ -338,7 +338,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/sdk/text-turn/tool-schemas.expected.json b/snapshots/sdk/text-turn/tool-schemas.expected.json index e8fd1b5981..b0f83fcf2c 100644 --- a/snapshots/sdk/text-turn/tool-schemas.expected.json +++ b/snapshots/sdk/text-turn/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/agent-instructions/tool-schemas.expected.json b/snapshots/session/agent-instructions/tool-schemas.expected.json index 36df7a0481..bf8ac27c56 100644 --- a/snapshots/session/agent-instructions/tool-schemas.expected.json +++ b/snapshots/session/agent-instructions/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -1023,7 +1023,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index 8511e0eff3..91fec1b0b3 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -159,7 +159,7 @@ interface ToolArgsMap { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & 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 a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; diff --git a/snapshots/session/both-mode-turn/tool-schemas.expected.json b/snapshots/session/both-mode-turn/tool-schemas.expected.json index 9893105262..0808055d84 100644 --- a/snapshots/session/both-mode-turn/tool-schemas.expected.json +++ b/snapshots/session/both-mode-turn/tool-schemas.expected.json @@ -343,7 +343,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/code-mode-read-image/system-prompt.expected.md b/snapshots/session/code-mode-read-image/system-prompt.expected.md index 42106b19ac..94268a29ff 100644 --- a/snapshots/session/code-mode-read-image/system-prompt.expected.md +++ b/snapshots/session/code-mode-read-image/system-prompt.expected.md @@ -161,7 +161,7 @@ interface ToolArgsMap { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & 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 a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; diff --git a/snapshots/session/code-mode-turn/system-prompt.expected.md b/snapshots/session/code-mode-turn/system-prompt.expected.md index ed2959eb01..463e92e87c 100644 --- a/snapshots/session/code-mode-turn/system-prompt.expected.md +++ b/snapshots/session/code-mode-turn/system-prompt.expected.md @@ -161,7 +161,7 @@ interface ToolArgsMap { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & 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 a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; diff --git a/snapshots/session/compaction-recovery/tool-schemas.expected.json b/snapshots/session/compaction-recovery/tool-schemas.expected.json index 36df7a0481..bf8ac27c56 100644 --- a/snapshots/session/compaction-recovery/tool-schemas.expected.json +++ b/snapshots/session/compaction-recovery/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -1023,7 +1023,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index 93ad87e990..e22b4f46be 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -326,7 +326,7 @@ interface ToolArgsMap { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & 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 a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index 3a0073f42a..1e06094a86 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -540,7 +540,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json index 47769041bd..3e20fa54a9 100644 --- a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json +++ b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json @@ -242,7 +242,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/lsp-definition/tool-schemas.expected.json b/snapshots/session/lsp-definition/tool-schemas.expected.json index 05543b4012..e06fd3dd11 100644 --- a/snapshots/session/lsp-definition/tool-schemas.expected.json +++ b/snapshots/session/lsp-definition/tool-schemas.expected.json @@ -359,7 +359,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/product-subagent-both/tool-schemas.expected.json b/snapshots/session/product-subagent-both/tool-schemas.expected.json index e457070e01..28cd2bfe72 100644 --- a/snapshots/session/product-subagent-both/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-both/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/product-subagent-codex/tool-schemas.expected.json b/snapshots/session/product-subagent-codex/tool-schemas.expected.json index bc41f13b88..d686182444 100644 --- a/snapshots/session/product-subagent-codex/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-codex/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json index 2072fb3d15..fc83c76008 100644 --- a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json index 144d2309e9..57c00f0d36 100644 --- a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json +++ b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/ralph-loop/tool-schemas.1.expected.json b/snapshots/session/ralph-loop/tool-schemas.1.expected.json index dbcc5636e5..d811ee3ef3 100644 --- a/snapshots/session/ralph-loop/tool-schemas.1.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.1.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/ralph-loop/tool-schemas.2.expected.json b/snapshots/session/ralph-loop/tool-schemas.2.expected.json index dbcc5636e5..d811ee3ef3 100644 --- a/snapshots/session/ralph-loop/tool-schemas.2.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.2.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/session-query-spill/tool-schemas.expected.json b/snapshots/session/session-query-spill/tool-schemas.expected.json index 3e28d4cfcb..53f2ce2a61 100644 --- a/snapshots/session/session-query-spill/tool-schemas.expected.json +++ b/snapshots/session/session-query-spill/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json index 796bac1779..7f7a4f3b7d 100644 --- a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json index 7e4dfe696c..91ba1b468a 100644 --- a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json +++ b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json @@ -385,7 +385,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/text-turn/tool-schemas.expected.json b/snapshots/session/text-turn/tool-schemas.expected.json index c93fb6d65f..5026bfafd6 100644 --- a/snapshots/session/text-turn/tool-schemas.expected.json +++ b/snapshots/session/text-turn/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/web-fetch/tool-schemas.expected.json b/snapshots/session/web-fetch/tool-schemas.expected.json index ee18836349..2a1137c188 100644 --- a/snapshots/session/web-fetch/tool-schemas.expected.json +++ b/snapshots/session/web-fetch/tool-schemas.expected.json @@ -322,7 +322,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/web/code-mode-round/system-prompt.expected.md b/snapshots/web/code-mode-round/system-prompt.expected.md index 6cda9dc20d..8708496a0c 100644 --- a/snapshots/web/code-mode-round/system-prompt.expected.md +++ b/snapshots/web/code-mode-round/system-prompt.expected.md @@ -189,7 +189,7 @@ interface ToolArgsMap { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & 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 a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; diff --git a/snapshots/web/cordis-tool-round/tool-schemas.expected.json b/snapshots/web/cordis-tool-round/tool-schemas.expected.json index b558e1d094..676cae57bd 100644 --- a/snapshots/web/cordis-tool-round/tool-schemas.expected.json +++ b/snapshots/web/cordis-tool-round/tool-schemas.expected.json @@ -582,7 +582,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index 8232bc9e23..baaf71d07f 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/snapshots/web/fresh-round-trip/tool-schemas.expected.json @@ -385,7 +385,7 @@ }, { "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.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. If it is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the subagent — only confirmation that the message was delivered. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { diff --git a/tsconfig.base.json b/tsconfig.base.json index 374f07f58f..e2d69ed877 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-session-title/types": ["./packages/session/session-title/src/types.ts"], "@deepseek-ai/dsh-session-title/client": ["./packages/session/session-title/src/client.ts"], "@deepseek-ai/dsh-subagent/client": ["./packages/subagent/subagent/src/client.ts"], + "@deepseek-ai/dsh-subagent/internal": ["./packages/subagent/subagent/src/internal.ts"], "@deepseek-ai/dsh-workspace/types": ["./packages/workspace/workspace/src/types.ts"], "@deepseek-ai/dsh-util-workspace-path": ["./packages/util/workspace-path/src/index.ts"], "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"], From 9effa0c6b3ab5682b634df98092dd179cfa95ff3 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 01:34:18 +0800 Subject: [PATCH 022/429] feat(ui-chat): split turn stats into usage and time pills with dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn tail's exposed meta line collapses into two icon pills — Usage (database glyph, turn total) and Ran-for (clock glyph, wall time) — each click-opening a details dialog; the calendar clock trails as plain text. Cache hit, TPS, and TTFT move dialog-only, and narrow viewports collapse the pills to bare icons. The TEMPORARY flat variant keeps the whole-line trigger for the A/B test. --- .../conversation-expanded.expected.md | 5 +- .../conversation.expected.md | 5 +- .../markdown-cjk-strong/ui.expected.md | 5 +- .../expected/markdown-images/ui.expected.md | 5 +- .../markdown-inline-code-links/ui.expected.md | 5 +- .../expected/math-rendering/ui.expected.md | 5 +- .../skill-user-invoke/ui-expanded.expected.md | 5 +- .../expected/skill-user-invoke/ui.expected.md | 5 +- .../stats-paged-history/ui.expected.md | 140 ++++++++++--- .../steer-all/settled-expanded.expected.md | 5 +- .../expected/steer-all/settled.expected.md | 5 +- apps/web/tests/turn-tail-actions.e2e.ts | 20 +- .../client/chat/MessageIconActions.module.css | 26 +-- .../src/client/chat/MessageIconActions.tsx | 42 +--- .../src/client/chat/TurnTailNodeView.tsx | 43 +++- .../src/client/chat/TurnUsagePanel.module.css | 95 +++++++-- .../src/client/chat/TurnUsagePanel.tsx | 198 ++++++++++++++++-- packages/client/ui-chat/src/client/locale.ts | 10 +- .../ui-chat/tests/chat-view.client.spec.tsx | 70 ++++++- .../tests/turn-usage-panel.client.spec.tsx | 98 ++++++++- .../client/ui-primitives/src/icons/index.tsx | 17 ++ snapshots/web/code-mode-round/ui.expected.md | 5 +- .../web/cordis-tool-round/ui.expected.md | 15 +- .../feedback-command/ack-expanded.expected.md | 5 +- .../web/feedback-command/ack.expected.md | 5 +- .../feedback-release/ack-expanded.expected.md | 5 +- .../web/feedback-release/ack.expected.md | 5 +- .../fresh-round-trip/ui-expanded.expected.md | 5 +- snapshots/web/fresh-round-trip/ui.expected.md | 5 +- .../ui-expanded.expected.md | 10 +- .../goal-multi-turn-actions/ui.expected.md | 10 +- .../reloaded-expanded.expected.md | 5 +- .../web/lifecycle-chrome/reloaded.expected.md | 5 +- .../cancel-expanded.expected.md | 5 +- .../web/live-interactions/cancel.expected.md | 5 +- .../retry-expanded.expected.md | 5 +- .../web/live-interactions/retry.expected.md | 5 +- snapshots/web/message-actions/ui.expected.md | 11 +- snapshots/web/minimal-preset/ui.expected.md | 5 +- .../plan-review/approved-expanded.expected.md | 5 +- .../web/plan-review/approved.expected.md | 5 +- .../answered-expanded.expected.md | 5 +- .../question-composer/answered.expected.md | 5 +- .../preserved-expanded.expected.md | 5 +- .../web/queue-actions/preserved.expected.md | 5 +- .../seeded-history/command-row.expected.md | 5 +- .../seeded-history/feedback-row.expected.md | 5 +- .../seeded-history/ui-expanded.expected.md | 5 +- snapshots/web/seeded-history/ui.expected.md | 5 +- snapshots/web/skill-tool-row/ui.expected.md | 5 +- .../web/steering/settled-expanded.expected.md | 5 +- snapshots/web/steering/settled.expected.md | 5 +- .../ui-expanded.expected.md | 10 +- .../web/subagent-conversation/ui.expected.md | 10 +- .../turn-tail-actions/completed.expected.md | 9 +- .../web/turn-tail-actions/focused.expected.md | 9 +- .../web/turn-tail-actions/settled.expected.md | 5 +- .../usage-expanded.expected.md | 9 +- snapshots/web/web-search-round/ui.expected.md | 5 +- snapshots/web/workflow-run/ui.expected.md | 5 +- 60 files changed, 834 insertions(+), 218 deletions(-) diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 1459a2aa8a..3a29a14b51 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -44,7 +44,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index c4f9670888..0a5fe67b90 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 65c73793b2..2fba524c5d 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -44,7 +44,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3c6a9475f8..3843143d1f 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -23,7 +23,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 228443ee1e..2d2127cbb2 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -35,7 +35,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index e063215b77..fba05ffe71 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -39,7 +39,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index f06169c311..07be7102ff 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 3281b27094..7ddf071159 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index dc2200a0b3..fc8039e282 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -48,7 +48,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m2 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m2 7/25 {{clock}} - button "Copy": - img - paragraph: r2 @@ -60,7 +63,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m3 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m3 7/25 {{clock}} - button "Copy": - img - paragraph: r3 @@ -72,7 +78,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m4 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m4 7/25 {{clock}} - button "Copy": - img - paragraph: r4 @@ -84,7 +93,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m5 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m5 7/25 {{clock}} - button "Copy": - img - paragraph: r5 @@ -96,7 +108,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m6 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m6 7/25 {{clock}} - button "Copy": - img - paragraph: r6 @@ -108,7 +123,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m7 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m7 7/25 {{clock}} - button "Copy": - img - paragraph: r7 @@ -120,7 +138,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m8 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m8 7/25 {{clock}} - button "Copy": - img - paragraph: r8 @@ -132,7 +153,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m9 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m9 7/25 {{clock}} - button "Copy": - img - paragraph: r9 @@ -144,7 +168,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m10 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m10 7/25 {{clock}} - button "Copy": - img - paragraph: r10 @@ -156,7 +183,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m11 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m11 7/25 {{clock}} - button "Copy": - img - paragraph: r11 @@ -168,7 +198,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m12 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m12 7/25 {{clock}} - button "Copy": - img - paragraph: r12 @@ -180,7 +213,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m13 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m13 7/25 {{clock}} - button "Copy": - img - paragraph: r13 @@ -192,7 +228,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m14 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m14 7/25 {{clock}} - button "Copy": - img - paragraph: r14 @@ -204,7 +243,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m15 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m15 7/25 {{clock}} - button "Copy": - img - paragraph: r15 @@ -216,7 +258,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m16 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m16 7/25 {{clock}} - button "Copy": - img - paragraph: r16 @@ -228,7 +273,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m17 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m17 7/25 {{clock}} - button "Copy": - img - paragraph: r17 @@ -240,7 +288,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m18 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m18 7/25 {{clock}} - button "Copy": - img - paragraph: r18 @@ -252,7 +303,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m19 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m19 7/25 {{clock}} - button "Copy": - img - paragraph: r19 @@ -264,7 +318,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m20 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m20 7/25 {{clock}} - button "Copy": - img - paragraph: r20 @@ -276,7 +333,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m21 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m21 7/25 {{clock}} - button "Copy": - img - paragraph: r21 @@ -288,7 +348,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m22 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m22 7/25 {{clock}} - button "Copy": - img - paragraph: r22 @@ -300,7 +363,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m23 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m23 7/25 {{clock}} - button "Copy": - img - paragraph: r23 @@ -312,7 +378,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m24 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m24 7/25 {{clock}} - button "Copy": - img - paragraph: r24 @@ -324,7 +393,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m25 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m25 7/25 {{clock}} - button "Copy": - img - paragraph: r25 @@ -336,7 +408,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m26 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m26 7/25 {{clock}} - button "Copy": - img - paragraph: r26 @@ -348,7 +423,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m27 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m27 7/25 {{clock}} - button "Copy": - img - paragraph: r27 @@ -360,7 +438,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} m28 7/25 {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} m28 7/25 {{clock}} - button "Copy": - img - paragraph: r28 @@ -372,7 +453,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - button "Back to bottom": - img - textbox "Message or run a task... / commands, @ files or sessions" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index c220ca0d5d..73e987dfa2 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -46,7 +46,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 7a9146a5b5..cd48c29075 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -34,7 +34,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 99b2572d60..40f5f0e017 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -173,10 +173,12 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ }) await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1) expect(await trigger.getAttribute('aria-expanded')).toBe('false') - // The pill carries the icon, the turn total, and the cache-hit rate; - // timing facts stay in the plain meta line beside it. - expect(await trigger.textContent()).toBe('Usage 15.8K tok · Cache hit 49.7%') - expect(await page.getByText(/^.+ · Ran for .+ · \d+ tok\/s · TTFT \S+$/).count()).toBe(1) + // The usage pill carries the icon and the turn total; the time pill beside + // it carries the run time, and both keep their details dialog-only. + expect(await trigger.textContent()).toBe('Usage 15.8K tok') + const timeTrigger = page.getByRole('button', { name: /^Ran for \S+$/ }) + expect(await timeTrigger.count()).toBe(1) + expect(await page.locator('[data-turn-tail]').getByText(/tok\/s|TTFT/).count()).toBe(0) expect(await page.getByRole('dialog').count()).toBe(0) await trigger.click() @@ -189,6 +191,16 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(await dialog.getByText('7,808 tok', { exact: true }).count()).toBe(1) expect(await dialog.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1) expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1) + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(0) + + await timeTrigger.click() + const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' }) + expect(await timeDialog.count()).toBe(1) + expect(await timeDialog.getByText(/tok\/s/).count()).toBe(1) + expect(await timeDialog.getByText('Average time to first token (TTFT)', { exact: true }).count()).toBe(1) + await page.keyboard.press('Escape') + await trigger.click() const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE) diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css index 37b48b77dc..2f6ec71c85 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css @@ -20,36 +20,16 @@ white-space: nowrap; } -/* The assistant tail's clock reads the secondary tier: its row carries the - detailed-usage cluster, so the whole meta line sits one step under body. */ +/* The assistant tail's clock sits at the row end after the stat pills; the + row's 8px flex gap spaces it, and it reads the secondary tier so it matches + the pill labels beside it. */ .timeEnd { - padding-left: 12px; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); white-space: nowrap; } -/* Separator between the clock and the run-time label (time · Ran for 15s). */ -.runTimeDot { - margin: 0 5px; -} - -/* Separator between the usage pill and the plain meta text; the row's 8px - flex gap spaces it, so the meta line drops its own 12px inset. The negative - margin rebates most of the pill's internal 8px padding so the label-to-dot - distance reads like the dot's other side, while keeping 2px clear of the - pill's hover background. */ -.actionsDot { - margin-left: -6px; - font-size: var(--dsh-content-font-size-secondary, 13px); - color: var(--dsw-alias-label-tertiary); -} - -.actionsDot + .timeEnd { - padding-left: 0; -} - /* Row containers (user rows and turn tails) opt in with data-actions-reveal='hover': every row but the latest of its kind reveals the entire actions row (icons and text) on hover or focus-within, while the diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index b53ff20ae6..5476f1a5c0 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -1,14 +1,12 @@ // Shared IconActions chrome for user and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { Fragment, useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { - formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond, -} from './message-chrome.ts' +import { formatMessageClock } from './message-chrome.ts' import { useCalendarDay } from './use-calendar-day.ts' import css from './MessageIconActions.module.css' @@ -17,12 +15,6 @@ export interface MessageIconActionsProps { text: string /** Unix epoch ms for the clock label; omitted for transient messages. */ time?: number | undefined - /** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */ - runMs?: number | undefined - /** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */ - tokensPerSecond?: number | undefined - /** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */ - ttftMs?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ @@ -51,7 +43,7 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, tokensPerSecond, ttftMs, clock, onBranch, branchUnavailable = false, className, + text, time, clock, onBranch, branchUnavailable = false, className, extraActions, usageAction, t, }: MessageIconActionsProps) { const day = useCalendarDay() @@ -82,30 +74,9 @@ export function MessageIconActions({ }, 1000) }) }, [copied, text]) - // The dots are decorative and stay hidden, but their margins separate the - // readings only on screen: without the flanking spaces a reader hears one - // run-on string ("8/26 22:08Ran for 13s") instead of separate facts. - const meta: string[] = [] - if (time !== undefined) meta.push(formatMessageClock(time, t, day)) - if (runMs !== undefined) meta.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) })) - if (tokensPerSecond !== undefined) { - meta.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) })) - } - if (ttftMs !== undefined) meta.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })) - const clockEl = meta.length === 0 ? null : ( + const clockEl = time === undefined ? null : ( - {meta.map((segment, index) => ( - - {index > 0 && ( - <> - {' '} - · - {' '} - - )} - {segment} - - ))} + {formatMessageClock(time, t, day)} ) return ( @@ -137,9 +108,6 @@ export function MessageIconActions({ {t('message.branchUnavailable')} )} {usageAction} - {usageAction != null && clock === 'end' && clockEl !== null && ( - · - )} {clock === 'end' ? clockEl : null}
) diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index 874eff5712..c54a093c36 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -2,7 +2,7 @@ import { memo } from 'react' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' import { MessageIconActions } from './MessageIconActions.tsx' -import { TurnUsagePanel } from './TurnUsagePanel.tsx' +import { TurnTimePanel, TurnUsagePanel } from './TurnUsagePanel.tsx' import { assistantText } from './turn-assistant.ts' import css from './TurnTailNodeView.module.css' @@ -28,6 +28,14 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ const runMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(0, turn.end.time - turn.start.time) + // TEMPORARY usage-variant debug switch for user testing: `?usage-variant=flat` + // or `#usage-variant=flat` (the hash form survives the login token's 303 + // redirect to a clean `/`) hands the whole meta line to TurnUsagePanel as the + // dialog trigger; the default keeps the icon pill beside plain meta text. + // Delete after the test. + const flatVariant = data.tokenUsage !== undefined + && [window.location.search, window.location.hash.replace(/^#/, '')] + .some(query => new URLSearchParams(query).get('usage-variant') === 'flat') // Interruption-frozen partials carry no messageId, so they address no // durable message and contribute no per-message actions. const messageId = closing.finalNode.messageId @@ -43,18 +51,37 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ {tail} { forkAt(closing.finalNode.seq) }} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} - usageAction={data.tokenUsage === undefined ? null : ( - - )} + usageAction={flatVariant + ? data.tokenUsage !== undefined && ( + + ) + : ( + <> + {data.tokenUsage !== undefined && } + {runMs !== undefined && ( + + )} + + )} t={t} />
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index f95534d7c0..d0af40f41f 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -7,6 +7,13 @@ min-width: 0; } +/* Adjacent stat pills (usage then time) rebate part of their combined internal + padding so the pair reads as one cluster inside the row's 8px flex gap, + while keeping 2px clear between their hover backgrounds. */ +.root + .root { + margin-left: -6px; +} + /* Same pill rules as the sibling `.action` icon buttons (28px hit height, 15px glyph, tertiary → secondary on hover with the interactive hover background), widened to carry the turn-total label. */ @@ -36,6 +43,36 @@ text-overflow: ellipsis; } +/* TEMPORARY (usage-variant debug): the flat whole-line trigger reads like the + plain meta text, brightening on hover to hint clickability. The margin + widens the row's 8px flex gap off the branch icon beside it. */ +.flatTrigger { + min-width: 0; + margin-left: 6px; + padding: 0; + border: none; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-variant-numeric: tabular-nums; + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.flatTrigger:hover, +.flatTrigger[aria-expanded='true'] { + color: var(--dsw-alias-label-secondary); +} + +/* TEMPORARY (usage-variant debug): flat-line separators breathe wider than the + plain meta line's 5px runTimeDot rhythm. */ +.flatDot { + margin: 0 8px; +} + .trigger svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); @@ -48,6 +85,13 @@ color: var(--dsw-alias-label-secondary); } +/* Narrow viewport: the pills collapse to bare icons; the dialogs keep the words. */ +@media (max-width: 480px) { + .trigger .label { + display: none; + } +} + /* Portal surface: fixed in the viewport, left/top supplied inline from the anchored-position clamp so the panel keeps its 12px viewport margin instead of hanging off the trigger and clipping at the window edge. Portaled panels @@ -73,13 +117,46 @@ cursor: default; } -/* Dialog heading, same size and weight as the total row it concludes with. */ +/* Dialog heading row: the section name left, the section's headline value + (the usage total) right, both at the primary weight. */ .title { - margin-bottom: 12px; + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; color: var(--dsw-alias-label-primary); font-weight: 500; } +/* Second-section heading (Turn time), spaced well off the usage rows above. */ +.timeTitle { + margin-top: 20px; +} + +/* Rule under each section heading, above its rows. */ +.titleRule { + margin-bottom: 10px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +.titleValue { + font-variant-numeric: tabular-nums; +} + +/* Section-heading glyph seated left of the section name. */ +.titleLabel { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.titleLabel svg { + width: 14px; + height: 14px; + flex: none; +} + .details { display: grid; grid-template-columns: minmax(76px, auto) minmax(0, 1fr); @@ -108,17 +185,3 @@ color: var(--dsw-alias-label-tertiary); white-space: nowrap; } - -/* Full-bleed rule above the total row: the grid's column gap would break a - per-cell border-top, so the line is its own row spanning both columns. */ -.separator { - grid-column: 1 / -1; - margin: 4px 0; - border-top: 1px solid var(--dsw-alias-border-l2); -} - -.totalLabel, -.details .totalValue { - color: var(--dsw-alias-label-primary); - font-weight: 500; -} diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index 79cbb6617b..7ef5d5489a 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -1,17 +1,48 @@ -// Icon-row Turn-usage action: a data-icon pill labelled with the turn total -// (and the cache-hit rate when known) that click-opens the per-Turn details -// dialog. Sits right of the branch action in the tail's IconActions row. +// Icon-row Turn-stat actions: a database pill labelled with the turn total +// click-opens the per-Turn usage dialog, and a clock pill labelled with the +// turn wall time click-opens the Turn-time dialog. Both sit right of the +// branch action in the tail's IconActions row, ahead of the plain clock text. +// TEMPORARY: the `flat` variant (usage-variant debug switch) renders the +// whole meta line as one trigger whose dialog stacks both sections; one of +// the two variants will be deleted after user testing. -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { Fragment, useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react' import { createPortal } from 'react-dom' -import { IconDataOutline16, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTokenUsage } from '../contract/chat-nodes.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { + formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond, +} from './message-chrome.ts' import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' +import { useCalendarDay } from './use-calendar-day.ts' import css from './TurnUsagePanel.module.css' export interface TurnUsagePanelProps { usage: TurnTokenUsage + /** Trigger form: the default icon pill or the temporary flat whole-line text. */ + variant?: 'pill' | 'flat' + /** Unix epoch ms for the flat trigger's clock segment. */ + time?: number | undefined + /** Turn wall time in ms; the flat trigger and its dialog's Turn-time section show it. */ + runMs?: number | undefined + /** Turn decode throughput; the flat trigger and its dialog's Turn-time section show it. */ + tokensPerSecond?: number | undefined + /** Turn first-step TTFT in ms; the flat trigger and its dialog's Turn-time section show it. */ + ttftMs?: number | undefined + /** The owning view's locale seat, passed down as a plain prop. */ + t: ChatViewSlotProps['t'] +} + +export interface TurnTimePanelProps { + /** Turn wall time in ms, the pill's label. */ + runMs: number + /** Turn decode throughput, a dialog row when known. */ + tokensPerSecond?: number | undefined + /** Turn first-step TTFT in ms, a dialog row when known. */ + ttftMs?: number | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -36,12 +67,16 @@ const PANEL_GAP = 8 */ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } -/** - * Turn-usage IconActions pill with a click-open Turn-details dialog. - * @param props - Turn usage buckets and locale seat. - * @returns The icon-and-total trigger and, while open, its portaled dialog anchored above the trigger. - */ -export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { +interface StatDialogSeat { + open: boolean + setOpen: (open: boolean) => void + rootRef: MutableRefObject + panelRef: MutableRefObject + pos: CSSProperties | null +} + +/** One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close. */ +function useStatDialog(): StatDialogSeat { const [open, setOpen] = useState(false) const rootRef = useRef(null) const panelRef = useRef(null) @@ -77,27 +112,88 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { } }, [open]) + return { open, setOpen, rootRef, panelRef, pos } +} + +/** Turn-time dialog rows: run time plus throughput and TTFT when known. */ +function TimeDetails({ runMs, tokensPerSecond, ttftMs, t }: { + runMs: number | undefined + tokensPerSecond: number | undefined + ttftMs: number | undefined + t: ChatViewSlotProps['t'] +}) { + return ( +
+ {runMs !== undefined && ( + <> +
{t('message.turnTime.duration')}
+
{formatRunDuration(runMs, t)}
+ + )} + {tokensPerSecond !== undefined && ( + <> +
{t('message.turnTime.speed')}
+
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
+ + )} + {ttftMs !== undefined && ( + <> +
{t('message.turnTime.ttft')}
+
{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}
+ + )} +
+ ) +} + +/** + * Turn-usage IconActions pill (or the temporary flat meta-line trigger) with + * a click-open Turn-usage details dialog. + * @param props - Turn usage buckets, trigger form, and locale seat. + * @returns The trigger and, while open, its portaled dialog anchored above the trigger. + */ +export function TurnUsagePanel({ usage, variant = 'pill', time, runMs, tokensPerSecond, ttftMs, t }: TurnUsagePanelProps) { + const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() + const day = useCalendarDay() + const cacheHit = usage.cacheReadTokens === undefined ? null : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1) const total = formatCompactCount(usage.totalTokens, t) const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? '' + const flatSegments: string[] = [] + if (variant === 'flat') { + if (time !== undefined) flatSegments.push(formatMessageClock(time, t, day)) + if (runMs !== undefined) flatSegments.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) })) + flatSegments.push(t('message.turnUsage.consumed', { total })) + if (cacheHit !== null) flatSegments.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit })) + if (tokensPerSecond !== undefined) { + flatSegments.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) })) + } + if (ttftMs !== undefined) flatSegments.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })) + } + const hasTimeFacts = runMs !== undefined || tokensPerSecond !== undefined || ttftMs !== undefined return ( {open && createPortal(
-
{t('message.turnUsage.title')}
+
+ + + {t('message.turnUsage.title')} + + {formatExactCount(usage.totalTokens, t)} +
+
{routes !== '' && ( <> @@ -144,10 +247,61 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { )} -
-
{t('message.turnUsage.total')}
-
{formatExactCount(usage.totalTokens, t)}
+ {variant === 'flat' && hasTimeFacts && ( + <> +
+ + + {t('message.turnTime.title')} + +
+
+ + + )} +
, + document.body, + )} + + ) +} + +/** + * Turn-time IconActions pill with a click-open Turn-time details dialog. + * @param props - Turn timing facts and locale seat. + * @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger. + */ +export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePanelProps) { + const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() + return ( + + + {open && createPortal( +
+
+ + + {t('message.turnTime.title')} + +
+
+
, document.body, )} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index cb34f59c7c..9236610d4f 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -101,8 +101,11 @@ export const zh = { 'message.turnUsage.cacheWrite': '缓存写入', 'message.turnUsage.output': '输出', 'message.turnUsage.reasoning': '(其中推理 {tokens})', - 'message.turnUsage.total': '总计', 'message.turnUsage.count': '{count} tok', + 'message.turnTime.title': '本轮用时和速度', + 'message.turnTime.duration': '本轮总用时', + 'message.turnTime.speed': '输出速度(TPS)', + 'message.turnTime.ttft': '首 token 平均用时(TTFT)', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', @@ -217,8 +220,11 @@ export const en = { 'message.turnUsage.cacheWrite': 'Cache write', 'message.turnUsage.output': 'Output', 'message.turnUsage.reasoning': ' ({tokens} reasoning)', - 'message.turnUsage.total': 'Total', 'message.turnUsage.count': '{count} tok', + 'message.turnTime.title': 'Turn time and speed', + 'message.turnTime.duration': 'Total run time', + 'message.turnTime.speed': 'Tokens per second (TPS)', + 'message.turnTime.ttft': 'Average time to first token (TTFT)', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'command.running': 'Running…', 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 ef60154e61..301f788590 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1562,19 +1562,71 @@ describe('ChatView', () => { }]]), }) const view = render() - // First-step ttft (1.2s) plus 100 tokens over 5s of decode stay plain - // text; the usage pill in the icon row is the only clickable trigger. + // The usage pill carries the compact total; cache hit stays dialog-only. const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ }) - expect(trigger.textContent).toBe('用量 10.1K tok · 缓存命中 49.4%') - expect(view.getByText(/用时 19秒/).textContent) - .toMatch(/^.+ · 用时 19秒 · 速度 20 tok\/s · 首 token 1\.2秒$/) + expect(trigger.textContent).toBe('用量 10.1K tok') expect(view.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) const dialog = view.getByRole('dialog') expect(dialog.getAttribute('aria-label')).toBe('本轮用量') + expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok') expect(dialog.textContent).toContain('缓存命中49.4%') expect(dialog.textContent).toContain('未缓存输入5,060 tok') - expect(dialog.textContent).toContain('总计10,100 tok') + fireEvent.keyDown(document, { key: 'Escape' }) + // The time pill carries the run time; first-step ttft (1.2s) and 100 + // tokens over 5s of decode move into its dialog. + const timeTrigger = view.getByRole('button', { name: /用时 19秒/ }) + expect(timeTrigger.textContent).toBe('用时 19秒') + expect(view.queryByText(/速度 20 tok\/s|首 token/)).toBeNull() + fireEvent.click(timeTrigger) + const timeDialog = view.getByRole('dialog') + expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度') + expect(timeDialog.textContent).toContain('本轮总用时19秒') + expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s') + expect(timeDialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒') + }) + + it('TEMPORARY: ?usage-variant=flat renders the whole meta line as the dialog trigger', () => { + window.history.replaceState(null, '', '?usage-variant=flat') + try { + const first: AssistantMessageNode = { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'draft' }], + timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 }, + usage: { outputTokens: 40 }, + } + const second: AssistantMessageNode = { + kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }], + timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 }, + usage: { outputTokens: 60 }, + } + const h = makeHarness({ + nodes: [user(1, 'hi'), first, second], + turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), + turnEnds: new Map([[1, 20]]), + turnUsages: new Map([[1, { + uncachedInputTokens: 5_060, + cacheReadTokens: 4_940, + outputTokens: 100, + totalTokens: 10_100, + }]]), + }) + const view = render() + const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ }) + // Separator dots live in spaced spans, so textContent carries bare `·`. + expect(trigger.textContent) + .toMatch(/^.+·用时 19秒·用量 10\.1K tok·缓存命中 49\.4%·速度 20 tok\/s·首 token 1\.2秒$/) + // The flat trigger absorbs the meta line, so no plain copy remains. + expect(view.getByText(/用时 19秒/)).toBe(trigger) + fireEvent.click(trigger) + const dialog = view.getByRole('dialog') + expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok') + expect(dialog.textContent).toContain('本轮用时和速度') + expect(dialog.textContent).toContain('本轮总用时19秒') + expect(dialog.textContent).toContain('输出速度(TPS)20 tok/s') + expect(dialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒') + } finally { + window.history.replaceState(null, '', window.location.pathname) + } }) it('withholds the usage-details trigger when turn usage is outside the window', () => { @@ -1589,9 +1641,9 @@ describe('ChatView', () => { turnEnds: new Map([[1, 20]]), }) const view = render() - // Timing facts stay visible as plain text, but with no usage in the - // window there is no pill to click. - expect(view.getByText(/首 token/)).toBeTruthy() + // Timing facts keep their pill, but with no usage in the window there is + // no usage pill to click. + expect(view.getByRole('button', { name: /用时/ })).toBeTruthy() expect(view.queryByRole('button', { name: /用量/ })).toBeNull() }) diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx index e97b88af10..596005c844 100644 --- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' -import { TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx' +import { TurnTimePanel, TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx' import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts' import { en } from '../src/client/locale.ts' @@ -26,7 +26,7 @@ describe('TurnUsagePanel', () => { const view = render() const trigger = view.getByRole('button') - expect(trigger.textContent).toBe('Usage 15.8K tok · Cache hit 49.4%') + expect(trigger.textContent).toBe('Usage 15.8K tok') expect(trigger.querySelector('svg')).not.toBeNull() expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') expect(trigger.getAttribute('aria-expanded')).toBe('false') @@ -36,9 +36,9 @@ describe('TurnUsagePanel', () => { expect(trigger.getAttribute('aria-expanded')).toBe('true') const dialog = view.getByRole('dialog') expect(dialog.getAttribute('aria-label')).toBe('Turn usage') - // Portaled out of the trigger's row, with a visible heading above the rows. + // Portaled out of the trigger's row, with a heading row carrying the total. expect(dialog.parentElement).toBe(document.body) - expect(dialog.firstChild?.textContent).toBe('Turn usage') + expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok') const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement expect(details).toBeTruthy() expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat') @@ -47,7 +47,7 @@ describe('TurnUsagePanel', () => { expect(details.textContent).toContain('Cached input4,940 tok') expect(details.textContent).toContain('Cache write0 tok') expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)') - expect(details.textContent).toContain('Total15,800 tok') + expect(details.textContent).not.toContain('Total') }) it('omits unavailable optional facts instead of inventing values', () => { @@ -77,9 +77,9 @@ describe('TurnUsagePanel', () => { } const view = render() const trigger = view.getByRole('button') - // The pill carries the compact total and cache-hit rate; exact token + // The pill carries the compact total; cache-hit rate and exact token // counts stay in the dialog. - expect(trigger.textContent).toBe('Usage 1.1K tok · Cache hit 99.9%') + expect(trigger.textContent).toBe('Usage 1.1K tok') fireEvent.click(trigger) const dialog = view.getByRole('dialog') @@ -95,4 +95,88 @@ describe('TurnUsagePanel', () => { fireEvent.pointerDown(document.body) expect(view.queryByRole('dialog')).toBeNull() }) + + it('TEMPORARY flat variant: the whole meta line triggers a dialog with usage and time sections', () => { + const usage: TurnTokenUsage = { + uncachedInputTokens: 5_060, + cacheReadTokens: 4_940, + outputTokens: 5_800, + totalTokens: 15_800, + } + const view = render( + , + ) + const trigger = view.getByRole('button') + // Separator dots live in spaced spans, so textContent carries bare `·`. + expect(trigger.textContent) + .toMatch(/^.+·Ran for 19s·Usage 15\.8K tok·Cache hit 49\.4%·20 tok\/s·TTFT 1\.2s$/) + expect(trigger.querySelector('svg')).toBeNull() + + fireEvent.click(trigger) + const dialog = view.getByRole('dialog') + expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok') + expect(dialog.textContent).toContain('Turn time and speed') + const timeDetails = dialog.querySelector('[data-turn-time-details]') as HTMLElement + expect(timeDetails.textContent).toContain('Total run time19s') + expect(timeDetails.textContent).toContain('Tokens per second (TPS)20 tok/s') + expect(timeDetails.textContent).toContain('Average time to first token (TTFT)1.2s') + }) + + it('TEMPORARY flat variant: absent time facts leave no Turn-time section', () => { + const usage: TurnTokenUsage = { + uncachedInputTokens: 120, + outputTokens: 30, + totalTokens: 150, + } + const view = render() + const trigger = view.getByRole('button') + expect(trigger.textContent).toBe('Usage 150 tok') + fireEvent.click(trigger) + const dialog = view.getByRole('dialog') + expect(dialog.textContent).not.toContain('Turn time') + expect(dialog.querySelector('[data-turn-time-details]')).toBeNull() + }) +}) + +describe('TurnTimePanel', () => { + it('shows a clock-and-duration pill and opens the time dialog on click', () => { + const view = render( + , + ) + const trigger = view.getByRole('button') + expect(trigger.textContent).toBe('Ran for 19s') + expect(trigger.querySelector('svg')).not.toBeNull() + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog') + expect(view.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + const dialog = view.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe('Turn time and speed') + expect(dialog.parentElement).toBe(document.body) + const details = dialog.querySelector('[data-turn-time-details]') as HTMLElement + expect(details.textContent).toContain('Total run time19s') + expect(details.textContent).toContain('Tokens per second (TPS)20 tok/s') + expect(details.textContent).toContain('Average time to first token (TTFT)1.2s') + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('dialog')).toBeNull() + }) + + it('omits unrecorded speed and TTFT rows', () => { + const view = render() + fireEvent.click(view.getByRole('button')) + const dialog = view.getByRole('dialog') + expect(dialog.textContent).toContain('Total run time3s') + expect(dialog.textContent).not.toContain('Tokens per second') + expect(dialog.textContent).not.toContain('Time to first token') + }) }) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 6f0a913f68..34bd5a8e13 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -761,6 +761,23 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** IconDataOutline16 without its gear: a three-tier database cylinder. */ +export const IconDatabaseOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + +) + +/** Thin-stroke clock: outlined dial with square-cut hour and minute hands. */ +export const IconClockOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + /** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */ export const IconSendOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md index 7d470fc968..6e590dabd7 100644 --- a/snapshots/web/code-mode-round/ui.expected.md +++ b/snapshots/web/code-mode-round/ui.expected.md @@ -49,7 +49,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index f58131af8c..78605cc160 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -72,7 +72,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "Thought for a while": - text: Thought for a while - img @@ -85,7 +88,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}} - button "Copy": - img - button "1 tool call" [expanded]: @@ -104,7 +110,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 6a97b82d8b..5b20a1e993 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."': - img - img diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 11f347f1d1..89f5b6f42d 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."': - img - img diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index c992c0db80..5b753960a5 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': - img - img diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index b6c5cf91bc..057d13cd9c 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': - img - img diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 1b72b70fe2..108ee4050c 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -44,7 +44,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 49dc055318..9a83925d23 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 79e0af28fb..700f499256 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -97,7 +97,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "System prompt": - img - img @@ -217,7 +220,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "Back to bottom": - img - textbox "Message or run a task... / commands, @ files or sessions" diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 32ac5cc62e..fd874dc327 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -42,7 +42,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "System prompt": - img - img @@ -107,7 +110,10 @@ - button "Branch into a new conversation": - img - tooltip "Branch into a new conversation" -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 9a45f772a8..00d271cff3 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 358fb187cc..bc5558ab03 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index c64e864ff8..b61cd9e51a 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -33,7 +33,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index c54a4aee60..4dbfb09b4a 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -29,7 +29,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index c341b22062..1c6a87f6d1 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -38,7 +38,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 695af671e3..93107fe00b 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 826509803f..53bfe16b3b 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -31,7 +31,11 @@ - img - button "Branch into a new conversation" [disabled]: - img -- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- text: Available only on the last message of a completed turn +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - button "Read a.txt": - img - img @@ -58,7 +62,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index f470df2a28..e26d5cdb11 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -33,7 +33,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index fa0facf822..0233cfa370 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -54,7 +54,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 8135c83a0f..14ba8a416e 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -34,7 +34,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 7c0a05c6c7..b2f7ba6294 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -46,7 +46,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 252ca47499..a6dc5b28fd 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -28,7 +28,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index d92f39fc8a..5a5143fc2c 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -33,7 +33,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "2 queued messages" [expanded] - list: - listitem: diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 66769e0488..9798e5da4f 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -29,7 +29,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "2 queued messages" [expanded] - list: - listitem: diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 8f6b5b5e95..3f4ed25eb3 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -47,7 +47,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 17eb33963d..1265a3e50e 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -47,7 +47,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 28e2a90ec2..2d18d8ec62 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -47,7 +47,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 5167001259..4711ff7304 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -29,7 +29,10 @@ - img - button "Branch into a new conversation": - img -- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: 7/25 {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 21e4c5c810..abf15517da 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -47,7 +47,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{date}} {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index f7a0b9b006..bfd48e7ed9 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -39,7 +39,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 62ed474ccf..0d190d89e9 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -31,7 +31,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index ac152fe9ed..6a275ca53f 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -44,7 +44,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "System prompt": - img - img @@ -68,7 +71,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 7212aa8d18..04d2e7885c 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - button "System prompt": - img - img @@ -56,7 +59,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index c7dd77a4a3..637055a274 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -28,10 +28,13 @@ - img - button "Branch into a new conversation": - img -- button "Usage 15.8K tok · Cache hit 49.7%": +- button "Usage 15.8K tok": - img - - text: Usage 15.8K tok · Cache hit 49.7% -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} + - text: Usage 15.8K tok +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index f8964fa6d7..94870d19c8 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -41,10 +41,13 @@ - img - button "Branch into a new conversation": - img -- button "Usage 15.8K tok · Cache hit 49.7%": +- button "Usage 15.8K tok": - img - - text: Usage 15.8K tok · Cache hit 49.7% -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} + - text: Usage 15.8K tok +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 90d71c1a86..26bd7a6d83 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -30,7 +30,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 466d21613a..43d984fc53 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -28,10 +28,13 @@ - img - button "Branch into a new conversation": - img -- button "Usage 15.8K tok · Cache hit 49.7%" [expanded]: +- button "Usage 15.8K tok" [expanded]: - img - - text: Usage 15.8K tok · Cache hit 49.7% -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} + - text: Usage 15.8K tok +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 5ceba38e31..0968ee2b75 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -36,7 +36,10 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 7f47cb6d63..4859403351 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -36,4 +36,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}} +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} From 6f16d5868c4fb2c3eec7ad384048125e1d89b375 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 02:14:37 +0800 Subject: [PATCH 023/429] refactor(ui-chat): drop the flat usage-variant debug switch and square narrow pills The A/B test settled on the twin-pill layout, so the TEMPORARY ?usage-variant=flat trigger, its locale keys, and its tests leave with it. Below 480px the stat pills now take the sibling action-button geometry so their bare icons keep the row's rhythm instead of drifting on the wider label padding and the -6px pair rebate. --- .../src/client/chat/TurnTailNodeView.tsx | 48 +++----- .../src/client/chat/TurnUsagePanel.module.css | 50 +++----- .../src/client/chat/TurnUsagePanel.tsx | 115 ++++-------------- packages/client/ui-chat/src/client/locale.ts | 6 - .../chat-font-axis-styles.client.spec.ts | 14 +++ .../ui-chat/tests/chat-view.client.spec.tsx | 43 ------- .../tests/turn-usage-panel.client.spec.tsx | 49 -------- 7 files changed, 66 insertions(+), 259 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index c54a093c36..36c76b7c26 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -28,14 +28,6 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ const runMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(0, turn.end.time - turn.start.time) - // TEMPORARY usage-variant debug switch for user testing: `?usage-variant=flat` - // or `#usage-variant=flat` (the hash form survives the login token's 303 - // redirect to a clean `/`) hands the whole meta line to TurnUsagePanel as the - // dialog trigger; the default keeps the icon pill beside plain meta text. - // Delete after the test. - const flatVariant = data.tokenUsage !== undefined - && [window.location.search, window.location.hash.replace(/^#/, '')] - .some(query => new URLSearchParams(query).get('usage-variant') === 'flat') // Interruption-frozen partials carry no messageId, so they address no // durable message and contribute no per-message actions. const messageId = closing.finalNode.messageId @@ -51,37 +43,25 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ {tail} { forkAt(closing.finalNode.seq) }} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} - usageAction={flatVariant - ? data.tokenUsage !== undefined && ( - - ) - : ( - <> - {data.tokenUsage !== undefined && } - {runMs !== undefined && ( - - )} - - )} + usageAction={( + <> + {data.tokenUsage !== undefined && } + {runMs !== undefined && ( + + )} + + )} t={t} />
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index d0af40f41f..e655a36810 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -43,36 +43,6 @@ text-overflow: ellipsis; } -/* TEMPORARY (usage-variant debug): the flat whole-line trigger reads like the - plain meta text, brightening on hover to hint clickability. The margin - widens the row's 8px flex gap off the branch icon beside it. */ -.flatTrigger { - min-width: 0; - margin-left: 6px; - padding: 0; - border: none; - background: transparent; - color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); - font-variant-numeric: tabular-nums; - line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - cursor: pointer; -} - -.flatTrigger:hover, -.flatTrigger[aria-expanded='true'] { - color: var(--dsw-alias-label-secondary); -} - -/* TEMPORARY (usage-variant debug): flat-line separators breathe wider than the - plain meta line's 5px runTimeDot rhythm. */ -.flatDot { - margin: 0 8px; -} - .trigger svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); @@ -85,11 +55,24 @@ color: var(--dsw-alias-label-secondary); } -/* Narrow viewport: the pills collapse to bare icons; the dialogs keep the words. */ +/* Narrow viewport: the pills collapse to bare icons with the sibling + `.action` geometry (28px circle, 6px padding, centered glyph); the + label-padding rebate no longer applies, so the pair keeps the row's plain + 8px rhythm. The dialogs keep the words. */ @media (max-width: 480px) { + .trigger { + justify-content: center; + width: calc(28px + var(--dsh-content-font-delta, 0px)); + padding: 6px; + } + .trigger .label { display: none; } + + .root + .root { + margin-left: 0; + } } /* Portal surface: fixed in the viewport, left/top supplied inline from the @@ -128,11 +111,6 @@ font-weight: 500; } -/* Second-section heading (Turn time), spaced well off the usage rows above. */ -.timeTitle { - margin-top: 20px; -} - /* Rule under each section heading, above its rows. */ .titleRule { margin-bottom: 10px; diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index 7ef5d5489a..a8ab03f6ab 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -2,36 +2,20 @@ // click-opens the per-Turn usage dialog, and a clock pill labelled with the // turn wall time click-opens the Turn-time dialog. Both sit right of the // branch action in the tail's IconActions row, ahead of the plain clock text. -// TEMPORARY: the `flat` variant (usage-variant debug switch) renders the -// whole meta line as one trigger whose dialog stacks both sections; one of -// the two variants will be deleted after user testing. -import { Fragment, useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react' +import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react' import { createPortal } from 'react-dom' import { IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTokenUsage } from '../contract/chat-nodes.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { - formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond, -} from './message-chrome.ts' +import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts' import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts' -import { useCalendarDay } from './use-calendar-day.ts' import css from './TurnUsagePanel.module.css' export interface TurnUsagePanelProps { usage: TurnTokenUsage - /** Trigger form: the default icon pill or the temporary flat whole-line text. */ - variant?: 'pill' | 'flat' - /** Unix epoch ms for the flat trigger's clock segment. */ - time?: number | undefined - /** Turn wall time in ms; the flat trigger and its dialog's Turn-time section show it. */ - runMs?: number | undefined - /** Turn decode throughput; the flat trigger and its dialog's Turn-time section show it. */ - tokensPerSecond?: number | undefined - /** Turn first-step TTFT in ms; the flat trigger and its dialog's Turn-time section show it. */ - ttftMs?: number | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -115,85 +99,31 @@ function useStatDialog(): StatDialogSeat { return { open, setOpen, rootRef, panelRef, pos } } -/** Turn-time dialog rows: run time plus throughput and TTFT when known. */ -function TimeDetails({ runMs, tokensPerSecond, ttftMs, t }: { - runMs: number | undefined - tokensPerSecond: number | undefined - ttftMs: number | undefined - t: ChatViewSlotProps['t'] -}) { - return ( -
- {runMs !== undefined && ( - <> -
{t('message.turnTime.duration')}
-
{formatRunDuration(runMs, t)}
- - )} - {tokensPerSecond !== undefined && ( - <> -
{t('message.turnTime.speed')}
-
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
- - )} - {ttftMs !== undefined && ( - <> -
{t('message.turnTime.ttft')}
-
{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}
- - )} -
- ) -} - /** - * Turn-usage IconActions pill (or the temporary flat meta-line trigger) with - * a click-open Turn-usage details dialog. - * @param props - Turn usage buckets, trigger form, and locale seat. + * Turn-usage IconActions pill with a click-open Turn-usage details dialog. + * @param props - Turn usage buckets and locale seat. * @returns The trigger and, while open, its portaled dialog anchored above the trigger. */ -export function TurnUsagePanel({ usage, variant = 'pill', time, runMs, tokensPerSecond, ttftMs, t }: TurnUsagePanelProps) { +export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() - const day = useCalendarDay() const cacheHit = usage.cacheReadTokens === undefined ? null : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1) const total = formatCompactCount(usage.totalTokens, t) const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? '' - const flatSegments: string[] = [] - if (variant === 'flat') { - if (time !== undefined) flatSegments.push(formatMessageClock(time, t, day)) - if (runMs !== undefined) flatSegments.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) })) - flatSegments.push(t('message.turnUsage.consumed', { total })) - if (cacheHit !== null) flatSegments.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit })) - if (tokensPerSecond !== undefined) { - flatSegments.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) })) - } - if (ttftMs !== undefined) flatSegments.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })) - } - const hasTimeFacts = runMs !== undefined || tokensPerSecond !== undefined || ttftMs !== undefined return ( {open && createPortal(
- {variant === 'flat' && hasTimeFacts && ( - <> -
- - - {t('message.turnTime.title')} - -
-
- - - )}
, document.body, )} @@ -301,7 +219,22 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan
- +
+
{t('message.turnTime.duration')}
+
{formatRunDuration(runMs, t)}
+ {tokensPerSecond !== undefined && ( + <> +
{t('message.turnTime.speed')}
+
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
+ + )} + {ttftMs !== undefined && ( + <> +
{t('message.turnTime.ttft')}
+
{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}
+ + )} +
, document.body, )} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9236610d4f..82a3329d05 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -88,12 +88,9 @@ export const zh = { 'message.maxTokens': '已达到输出 token 上限', 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', 'message.ranFor': '用时 {duration}', - 'message.ttft': '首 token {seconds}秒', 'message.tokensPerSecond': '{tps} tok/s', 'message.turnUsage.title': '本轮用量', 'message.turnUsage.consumed': '用量 {total}', - 'message.turnUsage.cacheHitRate': '缓存命中 {percent}%', - 'message.turnUsage.speed': '速度 {tps} tok/s', 'message.turnUsage.model': '提供方 / 模型', 'message.turnUsage.cacheHit': '缓存命中', 'message.turnUsage.input': '未缓存输入', @@ -207,12 +204,9 @@ export const en = { '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.', 'message.ranFor': 'Ran for {duration}', - 'message.ttft': 'TTFT {seconds}s', 'message.tokensPerSecond': '{tps} tok/s', 'message.turnUsage.title': 'Turn usage', 'message.turnUsage.consumed': 'Usage {total}', - 'message.turnUsage.cacheHitRate': 'Cache hit {percent}%', - 'message.turnUsage.speed': '{tps} tok/s', 'message.turnUsage.model': 'Provider / model', 'message.turnUsage.cacheHit': 'Cache hit', 'message.turnUsage.input': 'Uncached input', diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index a279c63c4d..4bbfe4dbba 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -117,6 +117,20 @@ describe('chat flow font-size axis', () => { ])) }) + it('narrow viewports collapse the stat pills to the action-button circle', () => { + // Below 480px the label hides and the pill takes the sibling `.action` + // geometry (28px width, 6px padding, centered glyph); the -6px + // label-padding rebate between adjacent pills resets so the icon pair + // keeps the row's plain 8px rhythm instead of overlapping. + const css = read('TurnUsagePanel.module.css') + const narrow = /@media \(max-width: 480px\) \{([\s\S]*?)\n\}/.exec(css)?.[1] ?? '' + expect(narrow).toMatch(/\.trigger \{[^}]*justify-content: center/) + expect(narrow).toMatch(/\.trigger \{[^}]*width: calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/) + expect(narrow).toMatch(/\.trigger \{[^}]*padding: 6px/) + expect(narrow).toMatch(/\.trigger \.label \{[^}]*display: none/) + expect(narrow).toMatch(/\.root \+ \.root \{[^}]*margin-left: 0/) + }) + it('non-latest turn tails hide the whole actions row until hover or focus', () => { // TurnTailNodeView tags its root data-actions-reveal='hover' for every // turn but the latest; the gate lives under @media (hover: hover) so 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 301f788590..6b72d28236 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1586,49 +1586,6 @@ describe('ChatView', () => { expect(timeDialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒') }) - it('TEMPORARY: ?usage-variant=flat renders the whole meta line as the dialog trigger', () => { - window.history.replaceState(null, '', '?usage-variant=flat') - try { - const first: AssistantMessageNode = { - kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'draft' }], - timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 }, - usage: { outputTokens: 40 }, - } - const second: AssistantMessageNode = { - kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }], - timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 }, - usage: { outputTokens: 60 }, - } - const h = makeHarness({ - nodes: [user(1, 'hi'), first, second], - turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), - turnEnds: new Map([[1, 20]]), - turnUsages: new Map([[1, { - uncachedInputTokens: 5_060, - cacheReadTokens: 4_940, - outputTokens: 100, - totalTokens: 10_100, - }]]), - }) - const view = render() - const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ }) - // Separator dots live in spaced spans, so textContent carries bare `·`. - expect(trigger.textContent) - .toMatch(/^.+·用时 19秒·用量 10\.1K tok·缓存命中 49\.4%·速度 20 tok\/s·首 token 1\.2秒$/) - // The flat trigger absorbs the meta line, so no plain copy remains. - expect(view.getByText(/用时 19秒/)).toBe(trigger) - fireEvent.click(trigger) - const dialog = view.getByRole('dialog') - expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok') - expect(dialog.textContent).toContain('本轮用时和速度') - expect(dialog.textContent).toContain('本轮总用时19秒') - expect(dialog.textContent).toContain('输出速度(TPS)20 tok/s') - expect(dialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒') - } finally { - window.history.replaceState(null, '', window.location.pathname) - } - }) - it('withholds the usage-details trigger when turn usage is outside the window', () => { const settled: AssistantMessageNode = { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }], diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx index 596005c844..c9ef6d45f1 100644 --- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx @@ -95,55 +95,6 @@ describe('TurnUsagePanel', () => { fireEvent.pointerDown(document.body) expect(view.queryByRole('dialog')).toBeNull() }) - - it('TEMPORARY flat variant: the whole meta line triggers a dialog with usage and time sections', () => { - const usage: TurnTokenUsage = { - uncachedInputTokens: 5_060, - cacheReadTokens: 4_940, - outputTokens: 5_800, - totalTokens: 15_800, - } - const view = render( - , - ) - const trigger = view.getByRole('button') - // Separator dots live in spaced spans, so textContent carries bare `·`. - expect(trigger.textContent) - .toMatch(/^.+·Ran for 19s·Usage 15\.8K tok·Cache hit 49\.4%·20 tok\/s·TTFT 1\.2s$/) - expect(trigger.querySelector('svg')).toBeNull() - - fireEvent.click(trigger) - const dialog = view.getByRole('dialog') - expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok') - expect(dialog.textContent).toContain('Turn time and speed') - const timeDetails = dialog.querySelector('[data-turn-time-details]') as HTMLElement - expect(timeDetails.textContent).toContain('Total run time19s') - expect(timeDetails.textContent).toContain('Tokens per second (TPS)20 tok/s') - expect(timeDetails.textContent).toContain('Average time to first token (TTFT)1.2s') - }) - - it('TEMPORARY flat variant: absent time facts leave no Turn-time section', () => { - const usage: TurnTokenUsage = { - uncachedInputTokens: 120, - outputTokens: 30, - totalTokens: 150, - } - const view = render() - const trigger = view.getByRole('button') - expect(trigger.textContent).toBe('Usage 150 tok') - fireEvent.click(trigger) - const dialog = view.getByRole('dialog') - expect(dialog.textContent).not.toContain('Turn time') - expect(dialog.querySelector('[data-turn-time-details]')).toBeNull() - }) }) describe('TurnTimePanel', () => { From f14f50416edbce804161c8618794c989a35ae67f Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 02:22:47 +0800 Subject: [PATCH 024/429] docs(notes): record the turn-tail stat pill decision --- .../2026-08-28-web-turn-stat-pills.i18n.yaml | 6 +++++ .../feature/2026-08-28-web-turn-stat-pills.md | 27 +++++++++++++++++++ .../2026-08-28-web-turn-stat-pills.zh.md | 27 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md create mode 100644 .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml new file mode 100644 index 0000000000..a044b62071 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.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-28-web-turn-stat-pills.md +2026-08-28-web-turn-stat-pills.md: cfba7db77507f88bdf1f03b80d62751830e5122d +2026-08-28-web-turn-stat-pills.zh.md: 05e5f193f246b3245c2ce169547950326fb7557f diff --git a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md new file mode 100644 index 0000000000..cfba7db775 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md @@ -0,0 +1,27 @@ +# Agent Note: Turn-tail stat pills with anchored dialogs + +Status: implemented + +English | [中文](2026-08-28-web-turn-stat-pills.zh.md) + +## Problem + +A completed assistant Turn ended with two stacked footer rows: a `Turn usage` DisclosureRow above the icon actions, and a meta line inside the actions row carrying clock, run time, TTFT, and decode speed as plain text. The disclosure expanded inline and shifted the transcript below it, the meta line mixed audience tiers — casual readers want the clock and run time while token buckets and latency percentiles are diagnostic — and the two-row footprint repeated under every Turn of a long transcript. + +## Decision + +The tail keeps one `MessageIconActions` row. Two stat pills sit right of the branch action: a database pill labelled with the compact Turn total (`Usage 15.8K tok`) and a clock pill labelled with the wall time (`Ran for 19s`); the message clock stays plain text at the row end. Each pill is an `aria-haspopup="dialog"` trigger that portals a fixed-position dialog to `document.body`, placed above the trigger by `useAnchoredPosition` with a 12px viewport clamp and closed by outside pointerdown or Escape (ContextMeter's pattern). The usage dialog holds the exact total, provider/model routes, cache-hit rate, token buckets, and the reasoning subset inline in Output; the time dialog holds total run time, decode TPS, and average TTFT. Facts absent from the fold render no row, and a window without publishable Turn usage renders no usage pill; the token-meter fold and `turn/start` gating are unchanged from [exact per-Turn usage](2026-08-24-web-per-turn-token-usage.md). + +Row visibility follows recency: turn tails and user rows tag `data-actions-reveal`, the latest of each kind stays `always` visible, earlier rows reveal on hover or focus-within under `@media (hover: hover)`, and no-hover devices keep every row visible. Below 480px the pill labels hide and each pill takes the sibling action-button geometry — 28px width, 6px padding, centered glyph, and no adjacent-pill margin rebate — so the bare icons keep the row's 8px rhythm. + +## Alternatives considered + +**One flat whole-line trigger.** A TEMPORARY `?usage-variant=flat` switch shipped both layouts to a live A/B session; the flat line exposing TTFT, TPS, and cache hit inline read as plain metadata with a weak click affordance, and its single dialog stacked two unrelated sections. The twin pills won the comparison and the switch, its locale keys, and its tests were deleted. + +**Keep the inline disclosure.** Rejected: expansion shifts the transcript, and the summary row spends a permanent second line on diagnostic data under every Turn. + +**Hover tooltips instead of dialogs.** Rejected: seven facts need a persistent, focusable surface, and hover cannot serve touch devices that the reveal gate already exempts. + +## Consequences + +`TurnUsageDisclosure` and its stylesheet are deleted; `TurnUsagePanel` owns both pills and dialogs, and `ui-chat` gains a `react-dom` dependency for the portal. Every web ARIA golden containing an assistant tail changed mechanically from `text: Ran for …` to a labelled button. Component tests pin trigger copy, dialog content, omission of absent facts, and both close paths; style-contract tests pin the secondary-tier pill typography, the recency gate, and the 480px collapse; the turn-tail e2e drives both dialogs on a recorded session and keeps tok/s and TTFT out of the tail row. diff --git a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md new file mode 100644 index 0000000000..05e5f193f2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Turn 尾部统计 pill 与锚定弹层 + +状态:已实现 + +[English](2026-08-28-web-turn-stat-pills.md) | 中文 + +## 问题 + +助手 Turn 完成后尾部有上下两行 footer:图标操作行上方的 `本轮用量` DisclosureRow,加上操作行内以纯文字承载时钟、用时、首 token、解码速度的 meta 行。折叠行行内展开会推移下方的对话内容;meta 行混杂了两级受众——普通读者只关心时钟和用时,token 分桶与延迟数据属于诊断信息;长对话里每个 Turn 下都重复这两行占位。 + +## 决定 + +尾部只保留一行 `MessageIconActions`。分叉操作右侧放两个统计 pill:数据库图标 pill 标注紧凑的本轮总量(`用量 15.8K tok`),时钟图标 pill 标注墙钟用时(`用时 19秒`);消息时钟保持纯文字置于行尾。每个 pill 是 `aria-haspopup="dialog"` 触发器,把固定定位的弹层 portal 到 `document.body`,由 `useAnchoredPosition` 锚定在触发器上方并保持 12px 视口边距,外部 pointerdown 或 Escape 关闭(沿用 ContextMeter 模式)。用量弹层承载精确总量、提供方/模型路由、缓存命中率、token 分桶及输出内联的推理子集;用时弹层承载本轮总用时、解码 TPS、首 token 平均用时。fold 未产出的事实不渲染行,窗口内无可发布的 Turn 用量则不渲染用量 pill;token-meter fold 与 `turn/start` 门控沿用[精确 per-Turn 用量](2026-08-24-web-per-turn-token-usage.zh.md),未做改动。 + +行可见性按新近度门控:turn 尾行与用户行标记 `data-actions-reveal`,各自最新一行保持 `always` 常显,更早的行在 `@media (hover: hover)` 下 hover 或 focus-within 才显示,无 hover 设备恒显示。480px 以下 pill 隐藏文字并取同排操作按钮的几何——28px 宽、6px 内边距、图标居中、取消相邻 pill 的边距补偿——让裸图标保持行的 8px 节奏。 + +## 备选方案 + +**整行扁平触发器。** TEMPORARY `?usage-variant=flat` 开关曾把两种布局同时交付真实 A/B 会话;扁平行把首 token、TPS、缓存命中率全部外露,读起来像普通元数据、点击暗示弱,且单一弹层堆叠两段无关内容。双 pill 胜出后,开关、其 locale key 与其测试一并删除。 + +**保留行内折叠行。** 否决:展开推移对话内容,且摘要行让诊断数据在每个 Turn 下永久占据第二行。 + +**用 hover tooltip 替代弹层。** 否决:七项事实需要可持久、可聚焦的面板,且 hover 无法服务 reveal 门控已豁免的触屏设备。 + +## 影响 + +`TurnUsageDisclosure` 及其样式表删除;`TurnUsagePanel` 拥有两个 pill 与弹层,`ui-chat` 为 portal 新增 `react-dom` 依赖。所有含助手尾行的 web ARIA golden 由 `text: Ran for …` 机械变为带标签按钮。组件测试钉住触发器文案、弹层内容、缺失事实的省略与两条关闭路径;样式契约测试钉住 pill 的次级字号、新近度门控与 480px 收缩;turn-tail e2e 在录制会话上驱动两个弹层,并确保 tok/s 与 TTFT 不出现在尾行。 From 206fb8b53ceaa9538d07f283342a9ce8f111cda3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 27 Aug 2026 23:20:38 +0800 Subject: [PATCH 025/429] test(session): resolve one relative root on both sides of the jsonl round-trip --- ...ase-insensitive-path-round-trips.i18n.yaml | 6 +++ ...08-14-case-insensitive-path-round-trips.md | 37 +++++++++++++++++++ ...14-case-insensitive-path-round-trips.zh.md | 37 +++++++++++++++++++ .../tests/jsonl.spec.ts | 9 ++++- 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md create mode 100644 .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md diff --git a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml new file mode 100644 index 0000000000..35c93a6998 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.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-14-case-insensitive-path-round-trips.md +2026-08-14-case-insensitive-path-round-trips.md: 5ff03a4fb55fc3dc136b22850f938685ed15a44f +2026-08-14-case-insensitive-path-round-trips.zh.md: 6ad6121653badd3f636dda822a95fa7672b22cc3 diff --git a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md new file mode 100644 index 0000000000..5ff03a4fb5 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md @@ -0,0 +1,37 @@ +# Agent Note: Case-insensitive path round-trips in test expectations + +Status: implemented + +English | [中文](2026-08-14-case-insensitive-path-round-trips.zh.md) + +## Problem + +[`packages/session/session-persistence-jsonl/tests/jsonl.spec.ts`](../../../../packages/session/session-persistence-jsonl/tests/jsonl.spec.ts) proves that a relative `root` is resolved before a session is located. It handed the plugin `relative(process.cwd(), absoluteRoot)` and built its expectation from `resolve(absoluteRoot)` — two different starting points for the same directory. + +On a case-insensitive filesystem those starting points can disagree on spelling. `path.relative()` on Windows compares case-insensitively and returns a path with the shared prefix removed, so the casing of that prefix is gone; `path.resolve()` then rebuilds it from `process.cwd()`. When the prefix `tmpdir()` and `process.cwd()` share is spelled with different casing in each, the plugin's resolved root carries the `cwd` spelling while the expectation carries the `tmpdir()` spelling, and `toEqual` compares two strings that name the same file. + +A host reaches that state when `tmpdir()` and `process.cwd()` share a path prefix but spell it differently — for example when `TMP` is mapped into the runner work tree under one spelling while the workspace path uses another. Sharing the tree is not enough on its own: if both spell the prefix alike, the round-trip returns the same string. The case fails there and passes everywhere else, which reads as a flake rather than as a fixed disagreement between two spellings. + +## Decision + +The expectation resolves the same relative root the plugin receives. Both sides pass through one `resolve(cwd, relative)` call, so the case-insensitive round-trip cannot place two spellings on the two sides of the comparison. + +This is a test-only change. The platform treats both spellings as the same file, so storage behaviour does not depend on which spelling `resolve()` produces. The string itself stays observable: hook payloads carry it as `transcript_path` and the shell contributor exports it as `DSH_SESSION_JSONL`, so a consumer that compares those strings can still see the difference. Composition fixtures such as [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) set a relative session root — but the plugin resolves whatever it receives before use, so a relative root reaches disk as one spelling rather than two. + +The case still asserts what it names: with the plugin's `resolve(config.root)` reduced to `config.root`, so a relative root is no longer resolved, the case fails. + +The neighbouring decision about constructing paths with the host `node:path` API lives in [cross-platform test fixtures](2026-07-22-cross-platform-test-fixtures.md); this note covers a different mechanism, the `relative()`/`resolve()` round-trip under a case-insensitive filesystem. + +## Alternatives considered + +**Compare the two paths case-insensitively.** This keeps the assertion green on the affected runners but accepts a real configuration disagreement as normal, and it would spread to every future path assertion rather than staying in the one case that round-trips through `relative()`. + +**Re-register the runners so `workFolder` matches the directory casing.** That repairs the underlying inconsistency, but `.runner` also carries the agent identity, pool, and server URLs, so hand-editing it risks a registration mismatch, and the test would remain fragile for any other host whose temp directory and working directory disagree on casing. + +**Normalize through `realpath()` in the expectation.** `realpath()` returns the on-disk casing, which is the `cwd` spelling here, so the case would pass; it also resolves symlinks, which changes what the assertion covers on hosts where the temp directory is a link. + +## Consequences + +The relative-root case now depends on `resolve()` alone rather than on the two spellings agreeing, so it passes on hosts whose temp directory and working directory disagree on casing. The underlying runner registration is untouched: a `workFolder` whose spelling differs from the directory on disk stays that way, so any future assertion that compares a `tmpdir()`-derived absolute path against a `cwd`-derived one will meet the same disagreement. + +The changed case passes, and the whole `session-persistence-jsonl` suite passes at 242 cases. The mechanism was reproduced away from Windows with `path.win32`: `relative()` on two differently-cased spellings of one directory returns a prefix-free relative path, `resolve()` rebuilds it from the `cwd` spelling, and the two absolute strings differ; with both sides spelled alike the same code matches. The regression check above — removing `resolve()` from the plugin — turns the case red while the fixture root and the working directory share a drive letter. Across drives `relative()` returns an absolute path, so both spellings already agree and the check cannot go red; the fixture roots come from `tmpdir()`, so the check only goes red where that path and the working directory share a drive. diff --git a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md new file mode 100644 index 0000000000..6ad6121653 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 测试期望值里的大小写不敏感路径往返 + +Status: implemented + +[English](2026-08-14-case-insensitive-path-round-trips.md) | 中文 + +## 问题 + +[`packages/session/session-persistence-jsonl/tests/jsonl.spec.ts`](../../../../packages/session/session-persistence-jsonl/tests/jsonl.spec.ts) 有一条用例验证「定位 session 之前会先解析相对 `root`」。它传给插件的是 `relative(process.cwd(), absoluteRoot)`,而期望值由 `resolve(absoluteRoot)` 算出——同一个目录、两个不同的起点。 + +在大小写不敏感的文件系统上,这两个起点的拼写可能不一致。Windows 的 `path.relative()` 按大小写不敏感比较,返回的是去掉公共前缀之后的相对路径,前缀的大小写信息随之丢失;随后 `path.resolve()` 用 `process.cwd()` 重新拼出前缀。当 `tmpdir()` 与 `process.cwd()` 共有的那段前缀在两者中拼写大小写不同时,插件解析出的 root 带的是 `cwd` 那种拼写,而期望值带的是 `tmpdir()` 那种拼写,于是 `toEqual` 比较的是指向同一个文件的两个字符串。 + +当 `tmpdir()` 与 `process.cwd()` 共享一段路径前缀、但两者对它的拼写不同时,主机就处在这个状态——例如把 `TMP` 以一种拼写映射进 runner 工作树、而 workspace 路径用另一种拼写。仅仅落在同一目录树内并不够:若两者的前缀拼写相同,往返会得到同一个字符串。该用例只在那里失败、别处都通过,看起来像 flake,实际是两种拼写之间一个固定的分歧。 + +## 决定 + +期望值改为解析「插件实际收到的那个相对 root」。两侧都经过同一次 `resolve(cwd, relative)`,大小写不敏感的往返就不可能把两种拼写分别放到比较的两边。 + +这是只改测试的变更。平台把两种拼写视为同一个文件,所以存储行为不依赖 `resolve()` 产出哪种拼写。字符串本身仍可被观察到:hook 载荷以 `transcript_path` 携带它,shell 贡献者以 `DSH_SESSION_JSONL` 导出它,因此比较这些字符串的消费方仍能看出差异。组合 fixture(测试前置数据)如 [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) 就设置了相对的会话 root——但插件会先解析收到的值再使用,因此相对 root 落盘时只有一种拼写而非两种。 + +该用例仍然在验证它声称的东西:把插件的 `resolve(config.root)` 降级成 `config.root`(即不再解析相对 root)后,用例转红。 + +关于「用宿主的 `node:path` API 构造路径」这一相邻决策,归属的 note 是[跨平台测试前置数据](2026-07-22-cross-platform-test-fixtures.zh.md);本 note 讲的是另一个机制——大小写不敏感文件系统上 `relative()`/`resolve()` 的往返。 + +## 考虑过的替代方案 + +**按大小写不敏感的方式比较两个路径。** 这能让受影响的 runner 上变绿,但等于把一个真实的配置分歧当成正常状态接受;而且这种写法会扩散到之后每一条路径断言,而不是留在唯一经由 `relative()` 往返的这一条里。 + +**重新注册 runner,让 `workFolder` 与目录大小写一致。** 这修的是底层的不一致,但 `.runner` 里同时存着 runner 的注册身份、pool 与 server URL,手工编辑有造成注册失配的风险;而且只要有别的宿主机的临时目录与工作目录大小写不一致,这条用例仍然是脆的。 + +**在期望值里用 `realpath()` 归一化。** `realpath()` 返回磁盘上的真实大小写,在这里就是 `cwd` 那种拼写,用例会通过;但它同时会解析符号链接,在临时目录本身是链接的宿主机上会改变该断言覆盖的内容。 + +## 后果 + +相对 root 那条用例现在只依赖 `resolve()` 本身,不再依赖两种拼写是否一致,因此在临时目录与工作目录大小写不一致的宿主机上也能通过。runner 注册本身未被改动:注册拼写与磁盘目录名不一致的状态会保持下去,所以今后任何拿 `tmpdir()` 派生的绝对路径去和 `cwd` 派生路径比较的断言,都会遇到同一个分歧。 + +改动后的用例通过,`session-persistence-jsonl` 整套 242 条用例通过。机制在非 Windows 环境用 `path.win32` 复现过:对同一目录的两种不同大小写拼写调用 `relative()` 会得到不含前缀的相对路径,`resolve()` 用 `cwd` 那种拼写重建,两个绝对字符串因此不同;把两侧拼写改成一致后,同一段代码即匹配。上面那条回归检查——把插件的 `resolve()` 去掉——在 fixture(测试前置数据)根与工作目录同盘符时会让用例转红。跨盘符时 `relative()` 返回绝对路径,两种拼写本就相同,该检查无法转红;本文件的 fixture 根来自 `tmpdir()`,所以只有该路径与工作目录同盘时该检查才会转红。 diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index e244d3af51..8b47eb96e6 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -171,17 +171,22 @@ describe('JsonlSessionPersistence: format helpers', () => { it('resolves a relative custom root before locating a session', async () => { const absoluteRoot = await freshRoot() + // Resolve the same relative root the plugin receives, not the original absolute + // path, so both sides of the comparison pass through one `resolve()` call. See + // .agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md + // for why an absolute root can arrive under a different casing. + const relativeRoot = relative(process.cwd(), absoluteRoot) const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(JsonlSessionPersistence, { - root: relative(process.cwd(), absoluteRoot), + root: relativeRoot, compression: 'none', writeBatchMaxDelayMs: 1, }) const m = meta('relative-location', '/work') expect(ctx.sessionPersistence.locate(m)).toEqual({ kind: 'jsonl', - path: rawLogPath(resolve(absoluteRoot), '/work', m.id), + path: rawLogPath(resolve(relativeRoot), '/work', m.id), }) await fiber.dispose() }) From 00f2a701bd02fe7581251d48038b1ef1091e79a3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 28 Aug 2026 04:44:22 +0800 Subject: [PATCH 026/429] test(scripts): align the translation-pairing-merge budget with the coverage lane Every case in the suite drives real git invocations against a scratch repository, so it is bound by process creation rather than by its assertions. The describe-level 15 s capped all 23 cases below the 90 s the Windows coverage lane passes as --testTimeout, and the suite has been observed timing out at 15000ms on a branch that did not touch the file. Refs #2677. --- ...translation-pairing-merge-budget.i18n.yaml | 6 ++++ ...-08-27-translation-pairing-merge-budget.md | 31 +++++++++++++++++++ ...-27-translation-pairing-merge-budget.zh.md | 31 +++++++++++++++++++ scripts/translation-pairing-merge.spec.ts | 10 +++++- 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.md create mode 100644 .agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.zh.md diff --git a/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.i18n.yaml b/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.i18n.yaml new file mode 100644 index 0000000000..f92bcc8e12 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.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-27-translation-pairing-merge-budget.md +2026-08-27-translation-pairing-merge-budget.md: 296e71fbbebf136602e02d2d8d64dbbfd8a336a3 +2026-08-27-translation-pairing-merge-budget.zh.md: 8bdac5902301d7105b7851e3c446a9a327b69819 diff --git a/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.md b/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.md new file mode 100644 index 0000000000..296e71fbbe --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.md @@ -0,0 +1,31 @@ +# Agent Note: Coverage-lane budget for the translation-pairing-merge suite + +Status: implemented + +English | [中文](2026-08-27-translation-pairing-merge-budget.zh.md) + +## Problem + +[`scripts/translation-pairing-merge.spec.ts`](../../../../scripts/translation-pairing-merge.spec.ts) took a `describe`-level `{ timeout: 15_000 }`. All 23 of its cases inherit that value; none carries an allowance of its own. + +Every case builds a scratch repository and drives it through spawned `git` invocations, so the suite is bound by process creation rather than by its assertions. On the self-hosted Windows runners all instances share one volume, and process creation there shows occasional multi-second spikes rather than a uniform slowdown. Under that contention this suite has been observed reporting `Test timed out in 15000ms` on a branch that did not touch the file, so the budget rather than the change under test decided the outcome. + +## Decision + +The suite takes `{ timeout: 90_000 }`, matching `DSH_COVERAGE_TEST_TIMEOUT_MS` in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml), which the Windows coverage lane passes as `--testTimeout`. + +A `describe` value takes precedence over that flag rather than deferring to it. A smaller one therefore lowers what the lane already grants, and because no case here carries its own allowance, every one of the 23 was capped at 15 s while the lane offered 90 s. + +## Consequences + +The suite tolerates a multi-second `git` spawn spike on the shared-volume runners and defers to the budget the coverage lane provides. The value is not a measurement of how long these cases need: the slowest three complete in roughly 0.7-1.2 s depending on the host, and raising the ceiling does not slow a passing run. + +A raised ceiling does not weaken the assertions: with the budget raised sixfold a suite still fails through its own assertions rather than through a timeout, because the ceiling only decides when waiting stops. It does widen what counts as acceptable duration, so a real slowdown from a few hundred milliseconds to tens of seconds now passes where the previous 15 s would have caught it. That detection is traded away deliberately: the 15 s ceiling was firing on contention rather than on regressions, so what it caught was the shared volume, not the code. + +## Alternatives considered + +**Raise `testTimeout` for the whole unit lane.** That would change every suite in the repository to fix one whose cost is specific to spawning `git`. + +**Give each case its own allowance.** Twenty-three separate values restate one property of the machine, and a later case added without one would silently inherit the lower ceiling again. + +**Leave the value and retry on failure.** A retry moves the failure to another case or another run and leaves a red gate that carries no information about the code under test. diff --git a/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.zh.md b/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.zh.md new file mode 100644 index 0000000000..8bdac59023 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.zh.md @@ -0,0 +1,31 @@ +# Agent Note: translation-pairing-merge 套件的 coverage lane 预算 + +Status: implemented + +[English](2026-08-27-translation-pairing-merge-budget.md) | 中文 + +## 问题 + +[`scripts/translation-pairing-merge.spec.ts`](../../../../scripts/translation-pairing-merge.spec.ts) 在 `describe` 层加了 `{ timeout: 15_000 }`。它的 23 个用例全部继承这个值,没有任何一个自带余量。 + +每个用例都会建一个临时仓库并通过 spawn 的 `git` 驱动它,因此这个套件受进程创建约束,而不是受它的断言约束。在自托管 Windows runner 上所有实例共用一个卷,而那里的进程创建表现为偶发的数秒尖峰,不是均匀变慢。在那种争抢下,这个套件曾在一个没有改动该文件的分支上报出 `Test timed out in 15000ms`,也就是说决定结果的是预算而不是被测改动。 + +## 决定 + +套件取 `{ timeout: 90_000 }`,与 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 里的 `DSH_COVERAGE_TEST_TIMEOUT_MS` 一致,Windows 覆盖率 lane 把它作为 `--testTimeout` 传入。 + +`describe` 层的取值优先于那个 flag,而不是让位于它。所以更小的值会压低 lane 已经给出的预算;又因为这里没有任何用例自带余量,23 个用例全部被限制在 15 秒,而 lane 提供的是 90 秒。 + +## 后果 + +套件能容忍共享卷 runner 上一次数秒的 `git` spawn 尖峰,并让位于 coverage lane 提供的预算。这个值不是对「这些用例需要多久」的测量:最慢的三个用例视主机而定约为 0.7–1.2 秒,而抬高上限不会让一次通过的运行变慢。 + +抬高上限不会削弱断言:把预算抬到六倍之后,套件仍然通过它自己的断言失败而不是通过超时失败,因为上限只决定何时停止等待。但它确实放宽了「多长算可接受」——一个从几百毫秒退化到几十秒的真实变慢现在会通过,而此前的 15 秒会拦住它。这项检测能力是有意换掉的:15 秒上限触发的是争抢而不是回归,所以它拦住的是共享卷,不是代码。 + +## 备选方案 + +**给整个 unit lane 抬高 `testTimeout`。** 那会为了修一个成本特定于 spawn `git` 的套件而改变仓库里的每一个套件。 + +**给每个用例各自加余量。** 23 个分散的取值重复表达同一个机器属性,而后续新增的用例若没写,又会静默继承较低的上限。 + +**保留取值、失败时重跑。** 重跑只是把失败挪到另一个用例或另一次运行,同时留下一个不携带被测代码信息的红灯。 diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 7293989c2e..91c84b2eca 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -261,7 +261,15 @@ function expectMergedPair(fixture: Fixture): void { ) } -describe('translation pairing merge composition', { timeout: 15_000 }, () => { +// Every case in this suite drives real `git` invocations against a scratch +// repository, so it is bound by process creation rather than by its assertions. +// The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the Windows coverage +// lane passes as --testTimeout: a describe value overrides that flag rather than +// yielding to it, so a smaller one here lowers what the lane grants every case +// in this file, none of which carries an allowance of its own. Measurements and +// the rejected alternatives are in +// .agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.md. +describe('translation pairing merge composition', { timeout: 90_000 }, () => { it('rejects a pairing-record path outside the repository', () => { const fixture = createFixture(false) From 452013effaf089c8e89f80c80b4c38fbdc0fcfe4 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 04:05:10 +0800 Subject: [PATCH 027/429] fix(client): web session and input UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch of visually verified Web UI fixes: trigger-menu z-index over the resize handle (#3228, #3229), input scrollbar offset, tool-row file links and diff stats (#3230), @ menu crumb alignment, light-mode divider, and @ menu flicker while typing (#3234) via stale-while-revalidate — a refinement hit keeps the previous items and highlight on screen until the new generation settles, so neither the skeleton nor the first-row focus blinks per keystroke. --- .../src/client/chat/ChatView.module.css | 4 ++- .../skeleton/ConversationRoot.module.css | 31 ++++++++++++++---- .../src/client/skeleton/ConversationRoot.tsx | 32 ++++++++++--------- .../src/client/skeleton/InputBar.module.css | 21 +++++++++--- .../src/client/MenuView.module.css | 10 ++++-- .../ui-input-trigger/src/client/MenuView.tsx | 6 ++-- .../client/ui-input-trigger/src/core/menu.ts | 14 ++++++-- .../tests/core-menu.client.spec.ts | 12 +++++-- .../tests/menu-view.client.spec.tsx | 9 ++++++ .../tests/service.client.spec.ts | 19 +++++++++++ .../client/ui-primitives/src/DiffBlock.tsx | 32 +++++++++++++------ packages/client/ui-primitives/src/index.ts | 2 +- .../ui-primitives/src/user-text.module.css | 25 +++++++++------ .../tests/user-text-styles.client.spec.ts | 6 ++-- .../client/tool/components/ToolRow.module.css | 28 +++++++++++++--- .../src/client/tool/components/ToolRow.tsx | 15 +++++++-- .../ui-tool/tests/diff-card.client.spec.tsx | 2 ++ 17 files changed, 203 insertions(+), 65 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 0dbb71f541..16090d200f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -175,7 +175,9 @@ position: sticky; bottom: 16px; /* Above the sticky composer (z-index 7) so the control stays clickable and - visible over the input card. */ + visible over the input card. Exception: while an @/slash menu is open the + seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never + covered by this control. */ z-index: 8; height: 0; display: flex; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 0451f499c3..9e139f5b5c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -1,5 +1,5 @@ .root { - position: relative; /* width handles are absolute against the column */ + position: relative; /* positioning context for slot-owned absolute chrome */ display: flex; flex-direction: column; height: 100%; @@ -36,9 +36,6 @@ .header { position: relative; - /* Above the width handles (which start at top: 0) so the breadcrumb row and - header buttons stay clickable. */ - z-index: 9; flex: none; padding: 12px 28px 0 20px; border-bottom: 1px solid transparent; @@ -202,8 +199,10 @@ } /* Width handles: 40px col-resize strips beside the transcript, absolute in - .root (NOT the scrollport: an absolute strip there would extend the - scrollable range). Inner edge sits 24px outside the content column — the + .body (NOT the scrollport: an absolute strip there would extend the + scrollable range; NOT .root: a strip from the column top would paint its + glow through the header's transparent background). Inner edge sits 24px + outside the content column — the same offset the glow line paints at — and the strip extends 40px outward from there; the outer edge is clamped to keep a 24px safe zone against the column edges (sidebar side and scrollbar side stay drag-free). When the @@ -315,6 +314,17 @@ flex: none; } +/* Band below the header: the scrollport plus the width handles. Anchoring the + handles here keeps their full-height strips and glow under the header's + bottom edge at every header height. */ +.body { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + .scrollBody { display: flex; flex: 1; @@ -360,6 +370,15 @@ ); } +/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside + the input card, so the seat's stacking context caps its z-index; lift the + seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while + the menu is open. The steady state stays 7 so the control keeps painting + over the card. */ +.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) { + z-index: 9; +} + /* Views may opt into a composer overlay while ConversationRoot retains ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 7b81d4db92..84bfbcce75 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -378,22 +378,24 @@ export function ConversationRoot({ return (
{sessionId === undefined ? null : renderSlot('conversation.session.header', {})} -
- {sessionId === undefined ? null : renderSlot('conversation.session', {})} - {composerSeat} +
+
+ {sessionId === undefined ? null : renderSlot('conversation.session', {})} + {composerSeat} +
+ {/* Width handles only while a transcript is on screen; the hero has no + content column to size. */} + {phase === 'active' && (['left', 'right'] as const).map(side => ( + + ))}
- {/* Width handles only while a transcript is on screen; the hero has no - content column to size. */} - {phase === 'active' && (['left', 'right'] as const).map(side => ( - - ))}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8230af0199..6576608916 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -117,6 +117,17 @@ .scroll { max-height: var(--dsh-composer-text-max-height); overflow-y: auto; + /* Keeps the bar off the card's right border; .input's right pad gives the + 4px back so the text column is unchanged. */ + margin-right: 4px; +} + +/* The card's 22px corner arc still overlaps the scrollport's top (10px top + pad reaches only y=10): start the thumb's travel below the arc so it never + pokes outside the capsule. WebKit-path only; Firefox's thin bar has no + track margin, an accepted remainder. */ +.scroll::-webkit-scrollbar-track { + margin-top: 8px; } /* Auto-grow anchor: the contenteditable is in normal flow and sets the @@ -140,10 +151,11 @@ } /* The contenteditable draft surface (grows with its content; .scroll caps - and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */ + and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4 — the + right pad is 8 here plus .scroll's 4px bar inset. */ .input { box-sizing: border-box; - padding: 4px 12px 0 16px; + padding: 4px 8px 0 16px; font-family: var(--dsw-font-family); font-size: inherit; line-height: inherit; @@ -170,10 +182,11 @@ color: var(--dsw-alias-label-caption); } -/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ +/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. + Right inset mirrors .input's 8px pad (the other 4px sits on .scroll). */ .placeholder { position: absolute; - inset: 4px 12px auto 16px; + inset: 4px 8px auto 16px; color: var(--dsw-alias-label-caption); pointer-events: none; user-select: none; diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index 3ef63823e5..42499397b8 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -185,16 +185,20 @@ } /* Breadcrumb header of a drilled source: pinned outside .viewport so the - descent stays reversible while the candidate list scrolls under it. */ + descent stays reversible while the candidate list scrolls under it. + 4px sides + the crumb's own 6px = the first crumb's text sits at 10px, + flush with .sectionTitle and the item icons. */ .crumbs { flex: none; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; - padding: 4px 6px 6px; + padding: 4px 4px 6px; margin-bottom: 2px; - border-bottom: 1px solid var(--dsw-alias-border-inverted); + /* l1, not border-inverted (the menu frame's dark-only stroke): the divider + needs to survive light mode too; in dark both resolve to white/0.06. */ + border-bottom: 1px solid var(--dsw-alias-border-l1); } /* Crumbs sit outside the listbox and take no keyboard highlight, so unlike .item they carry their own :hover tint with nothing to compete with. */ diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 7bdc0a76d2..64be92b20a 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -2,7 +2,9 @@ * Trigger candidate menu: renders the InputTriggerService menu store into the * conversation.input.overlay anchor. Closed state renders null (the overlay * slot stays mounted); groups render in roster order under localized title - * rows, pending groups as two skeleton rows; pointer picks route back through + * rows. A pending group keeps showing the items it already had (the reducer + * retains them across a query refinement) and falls back to two skeleton + * rows only while it has none; pointer picks route back through * the service (combobox pattern — focus never leaves the textarea, so rows * are mousedown-handled and the highlight is exposed via * aria-activedescendant on the listbox). A source publishing crumbs gets a @@ -114,7 +116,7 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t {group.showGroupTitle === false || group.items.some(item => item.section !== undefined) ? null :
{t(group.source as MenuKey)}
} - {group.status === 'pending' + {group.status === 'pending' && group.items.length === 0 ? (
diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts index 47a6090d8f..85787266a5 100644 --- a/packages/client/ui-input-trigger/src/core/menu.ts +++ b/packages/client/ui-input-trigger/src/core/menu.ts @@ -8,7 +8,10 @@ * reducer cannot invent groups. Opening from a closed state, the shell seeds * the roster with {@link seedGroups} and then dispatches `hit`; a `hit` * while open (query refinement) resets the existing groups to pending under - * a new generation. Auto-close and explicit close drop the groups. + * a new generation while keeping their items on screen until the new fetch + * settles (stale-while-revalidate — the render layer shows skeletons only + * for a pending group with no items). Auto-close and explicit close drop + * the groups. */ import type { InputTriggerCandidate, InputTriggerSource } from '../types.ts' import type { ExactMatch, MenuReduce, MenuState } from './contract.ts' @@ -96,8 +99,13 @@ export const menuReduce: MenuReduce = (state, ev) => { open: true, hit: ev.hit, generation: state.generation + 1, - groups: state.groups.map(g => ({ ...g, status: 'pending', items: [] })), - highlight: null, + // Items and highlight survive the refinement (stale-while-revalidate): + // the previous query's candidates stay rendered with the highlight + // parked where it was while the new fetch runs, and the settled + // generation replaces the items and revalidates the highlight + // wholesale. Pending status still fences picks off the stale rows. + groups: state.groups.map(g => ({ ...g, status: 'pending' })), + highlight: state.highlight, } } case 'source-settled': { diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts index 532f608fcf..62b8c7d01b 100644 --- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts @@ -36,13 +36,19 @@ describe('menuReduce hit', () => { expect(s.highlight).toBeNull() }) - it('re-hit resets ready groups to pending under a bumped generation', () => { + it('re-hit resets ready groups to pending under a bumped generation, keeping items and highlight', () => { let s = open(['command']) s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] }) s = menuReduce(s, { type: 'hit', hit: hit('g') }) expect(s.generation).toBe(2) - expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [] }]) - expect(s.highlight).toBeNull() + // Stale-while-revalidate: the previous query's items stay until the new + // generation settles and replaces them, and the highlight stays parked + // instead of blinking off between keystrokes. + expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [item('goal')] }]) + expect(s.highlight).toEqual({ source: 'command', index: 0 }) + s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'command', items: [item('grep')] }) + expect(s.groups).toEqual([{ source: 'command', status: 'ready', items: [item('grep')] }]) + expect(s.highlight).toEqual({ source: 'command', index: 0 }) }) it('preserves a hidden group title through re-hit and settlement', () => { 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 b174cb8a35..f9912ba1f9 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 @@ -123,6 +123,15 @@ describe('MenuView', () => { expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy() }) + it('renders retained items instead of skeletons while a refinement is pending', () => { + mount(openState({ + groups: [{ source: 'command', status: 'pending', items: [{ name: 'goal' }] }], + highlight: null, + })) + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual(['goal']) + expect(screen.queryByRole('status')).toBeNull() + }) + it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => { const { view } = mount(openState({ groups: [ 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 e63a736694..43eeba0f64 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -293,6 +293,25 @@ describe('track', () => { expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }]) }) + it('refinement keeps the settled items on screen until the new fetch lands', async () => { + const cmd = deferredSource('/', 'command') + const { controller } = controllerBench([cmd.source]) + controller.track('/g', 2, { tier: 'plain' }, 1) + cmd.pending[0]!.resolve([{ name: 'goal' }]) + await tick() + + // Stale-while-revalidate: the pending group still carries the items. + controller.track('/go', 3, { tier: 'plain' }, 1) + expect(controller.menu.getSnapshot().groups[0]).toEqual( + { source: 'command', status: 'pending', items: [{ name: 'goal' }] }, + ) + cmd.pending[1]!.resolve([{ name: 'goat' }]) + await tick() + expect(controller.menu.getSnapshot().groups[0]).toEqual( + { source: 'command', status: 'ready', items: [{ name: 'goat' }] }, + ) + }) + it('same hit re-track refreshes the span stamp without refetching', () => { const cmd = deferredSource('/', 'command') const { controller } = controllerBench([cmd.source]) diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 1c7925331e..01a080bbfc 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -62,21 +62,37 @@ const ROW_CLASS: Record = { gap: css.gap, } +/** + * Total added/removed line counts across hunks — the same numbers the footer + * prints, exported so a summary row can show them without rebuilding the body. + * Every old-side line counts toward `removed` and every new-side line toward + * `added`, under {@link contentLines}'s terminator rule. + * @param diffs - the hunks to count. + * @returns the +/- totals. + */ +export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const diff of diffs) { + if (diff.oldText !== null) removed += contentLines(diff.oldText).length + added += contentLines(diff.newText).length + } + return { added, removed } +} + /** * Flatten the hunks into the body's rows plus the footer counts. A path header * opens each new file; a same-file second hunk (a scattered edit) opens with a - * `⋯` gap instead of repeating the path. Every old-side line counts toward - * `removed` and every new-side line toward `added`. The file count is of - * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file - * read as `1 file` on both front ends. + * `⋯` gap instead of repeating the path. The +/- totals are + * {@link diffTotals}'s. The file count is of DISTINCT paths, matching the TUI + * diff card's footer, so two hunks in one file read as `1 file` on both front + * ends. * @param diffs - the hunks to render. * @returns the body rows, the +/- totals, and the distinct-file count. */ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } { const rows: DiffRow[] = [] const paths = new Set() - let added = 0 - let removed = 0 let prevPath: string | undefined for (const diff of diffs) { paths.add(diff.path) @@ -86,15 +102,13 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed if (diff.oldText !== null) { for (const line of contentLines(diff.oldText)) { rows.push({ kind: 'del', text: line }) - removed++ } } for (const line of contentLines(diff.newText)) { rows.push({ kind: 'add', text: line }) - added++ } } - return { rows, added, removed, files: paths.size } + return { rows, ...diffTotals(diffs), files: paths.size } } /** diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 8415fcf059..20371add21 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -40,7 +40,7 @@ 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, ReadBlockLabels } from './ReadBlock.tsx' -export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' +export { DiffBlock, DEFAULT_DIFF_MAX_LINES, diffTotals } from './DiffBlock.tsx' export type { DiffBlockProps, DiffHunk, DiffBlockLabels } from './DiffBlock.tsx' export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' export type { diff --git a/packages/client/ui-primitives/src/user-text.module.css b/packages/client/ui-primitives/src/user-text.module.css index 53a6fafe83..3e6f8ca4f0 100644 --- a/packages/client/ui-primitives/src/user-text.module.css +++ b/packages/client/ui-primitives/src/user-text.module.css @@ -9,20 +9,27 @@ } .refChip { - display: inline-flex; - align-items: center; - gap: 4px; + /* Plain inline, NOT inline-flex: a flex container takes its baseline from + its first flex item — the icon svg, which has no text baseline — so the + chip's label rode ~3px above the surrounding text in both surfaces. An + inline chip shares the consumer's baseline by construction. */ + display: inline; margin: 0 2px; color: var(--dsw-alias-state-business-primary); font-weight: 500; white-space: nowrap; - vertical-align: baseline; } -/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's text - size: the 16px svg edge moves by the same px delta as the surrounding font. */ +/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own + font: 1em keeps the glyph at the text's size in the bubble (14px + user + setting), the queue preview's fixed 13px line, and any future consumer — a + px+delta size followed only the bubble axis and left the glyph oversized in + the queue row. */ .refIcon { - flex: none; - width: calc(16px + var(--dsh-content-font-delta, 0px)); - height: calc(16px + var(--dsh-content-font-delta, 0px)); + width: 1em; + height: 1em; + margin-right: 4px; + /* Optical centering against the text: drops the glyph below the baseline by + a font-relative amount, so it holds across the font axis. */ + vertical-align: -0.125em; } diff --git a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts index 4b06c6f3b2..d80d4f5c13 100644 --- a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts @@ -18,10 +18,10 @@ function declarations(selector: string): string[] { } describe('user-text.module.css font-size axis', () => { - it('scales reference glyphs by the shared px delta', () => { + it('scales reference glyphs with the consumer font', () => { expect(declarations('.refIcon')).toEqual(expect.arrayContaining([ - 'width: calc(16px + var(--dsh-content-font-delta, 0px))', - 'height: calc(16px + var(--dsh-content-font-delta, 0px))', + 'width: 1em', + 'height: 1em', ])) }) }) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 4a3d5e46ef..2f344ac0dd 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -103,9 +103,28 @@ color: var(--dsw-alias-label-tertiary); } -/* File-tool path: same geometry as .summary, with a persistent link affordance. */ +/* Diff-row +/- totals: the code font, matching the diff body the numbers + summarize; a wider gap keeps the digits from reading as part of the path. + Two px under the secondary tier (still riding the axis): mono digits read + optically larger than the sans path at the same size. Caption, one step + dimmer than the suffix's tertiary, so the digits stay behind the path. The + half-pixel nudge closes the baseline gap the size difference leaves under + the row's box-centering (a transform, so flex layout is untouched). */ +.diffStat { + margin-left: 10px; + font-family: var(--ds-font-family-code); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px); + color: var(--dsw-alias-label-caption); + transform: translateY(0.5px); +} + +/* File-tool path: same type as .summary, with a persistent link affordance. + Dotted dimmed underline: visible enough to say "clickable", light enough + that a long path doesn't read as one heavy rule under the row. Shrink-to-fit + (flex 0): the click target ends where the path text ends — the row's empty + remainder stays the expand/collapse toggle. */ .fileLink { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -119,8 +138,9 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-decoration-color: var(--dsw-alias-label-quaternary); + text-decoration: underline dotted; + text-decoration-color: var(--dsw-alias-label-tertiary); + text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: pointer; } 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 ccf211e468..ab38bc9dbc 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, + diffTotals, } 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' @@ -129,7 +130,15 @@ export function ToolRow({ // A failure must replace, not supplement, the normal summary. const failureLine = state === 'error' ? errorSummary ?? null : null const summaryText = failureLine ?? terminalBody?.description ?? summary - const suffix = failureLine === null ? summarySuffix ?? null : null + // A diff row's collapsed line carries the card's +/- totals (the same + // numbers the expanded footer prints) so the change size reads without + // expanding; an explicit summarySuffix (none today on diff rows) wins. + const diffStat = useMemo(() => { + if (diffBody === null) return null + const { added, removed } = diffTotals(diffBody.card.diffs) + return `+${added} -${removed}` + }, [diffBody]) + const suffix = failureLine === null ? summarySuffix ?? diffStat : null const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null const toggleExpand = () => { setExpanded(v => !v) @@ -184,7 +193,9 @@ export function ToolRow({ {summaryText} )} - {suffix !== null && {suffix}} + {suffix !== null && ( + {suffix} + )} )} > 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 8a263f2983..ebe2b10351 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -252,6 +252,8 @@ describe('FileMutationRow diff card', () => { call: { name: 'write', argsRaw: writeArgs }, meta: { diffs: [] }, }), 'write')} />) + // The collapsed row already carries the card's +/- totals beside the path. + expect(view.getByText('+1 -0')).toBeTruthy() // The footer counts live inside the collapsed diff card. toggleRow(view) expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy() From d576865f76b39bc8435d4389bfc544a365d8b51e Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 11:03:41 +0800 Subject: [PATCH 028/429] fix(client): remove the hero glow under the new-session input The blurred blue ellipse (HeroGlow) below the homepage input card reads as stray tint rather than intentional chrome; drop the component and its positioning/overflow scaffolding. --- .../skeleton/ConversationRoot.module.css | 25 ------------ .../src/client/skeleton/ConversationRoot.tsx | 3 +- .../src/client/skeleton/EmptyHero.tsx | 38 +------------------ .../src/client/skeleton/HeroShell.module.css | 4 +- 4 files changed, 3 insertions(+), 67 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 9e139f5b5c..73c5fccb73 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -331,14 +331,6 @@ flex-direction: column; min-height: 0; overflow-y: auto; - /* The column scrolls on ONE axis. Stating `hidden` rather than leaving the - initial `visible` is what removes the horizontal bar: a box that scrolls in - one axis computes `visible` to `auto` in the other, so any bleed becomes - user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero - box), which put a horizontal scrollbar under every center column narrower - than the glow. Clipping is unchanged — `overflow-y: auto` already made this - a scroll container that clips both axes, so this only takes away the bar. */ - overflow-x: hidden; /* Reserved unconditionally: the composer seat rides this box's content box in Chat and its padding box under a view's composer overlay, so an `auto` gutter moves the input card sideways by the bar's width whenever the two @@ -423,7 +415,6 @@ NOT absolute+transform: a transform would make this box the containing block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { - position: relative; /* .heroGlow positioning context */ align-self: center; /* figma 75:8208 drew 12 between all three rows; the workspace row now sits 8 above the card (its margin-top restores 12 under the hero chrome). */ @@ -436,22 +427,6 @@ z-index: 1; } -/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the - card's resting center sits ~92px above the stack bottom (32 foot pad + - half of the ~120px two-row card); width tracks the card (glow asset 1051 - vs design card 776) so blur scales in userSpace with it. z-index -1 keeps - it behind the in-flow hero content inside this stacking context. */ -.heroGlow { - position: absolute; - left: 50%; - bottom: 92px; - z-index: -1; - width: calc(100% * 1051 / 776); - aspect-ratio: 1051 / 468; - transform: translate(-50%, 50%); - pointer-events: none; -} - .heroWorkspaceRow { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 84bfbcce75..eb9a3be4fd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -7,7 +7,7 @@ import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' import { conversationPhase } from '../contract/snapshot.ts' -import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' /** Full props composed from the slot contract. */ @@ -350,7 +350,6 @@ export function ConversationRoot({ const composerBar = (
- {hero && } {hero && } {hero && heroWorkspaceRow} {zone !== undefined && renderSlot('conversation.input.dock', zone)} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 519b1b95a4..10dfddc2d6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -1,7 +1,6 @@ // The composer remains in ConversationRoot so switching out of the blank-draft // phase does not remount its textarea. -import { useId } from 'react' import type { ReactNode, RefObject } from 'react' import { FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, @@ -61,40 +60,6 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t } ) } -/** - * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero - * owner (ConversationRoot), not HeroShell, so it can center on the input - * card; the owner's className supplies all positioning. - * @param props.className - positioning class from the owner. - * @returns the blurred-ellipse svg element. - */ -export function HeroGlow({ className }: { className?: string | undefined }) { - // Stable filter id so multiple hero mounts do not collide in the DOM. - const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` - return ( - - ) -} - /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */ export interface HeroShellProps { /** The owner's locale seat, passed down as a plain prop. */ @@ -106,8 +71,7 @@ export interface HeroShellProps { } /** - * Render the hero chrome (headline only; no glow, no composer, no workspace - * row — the glow is the owner's {@link HeroGlow}). + * Render the hero chrome (headline only; no composer, no workspace row). * @param props - see {@link HeroShellProps}. * @returns the centered hero element tree. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 91dfb4a8a3..937056122e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -96,9 +96,7 @@ } } -/* Workspace row sits 12px above the input card (figma y80 → y112). The blue - glow lives with the owner (ConversationRoot .heroGlow) so it can center on - the input card. */ +/* Workspace row sits 12px above the input card (figma y80 → y112). */ .body { position: relative; display: flex; From 1278877d919864141ea3d686de564a611aa86cc9 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 12:47:15 +0800 Subject: [PATCH 029/429] fix(client): dismiss stat dialogs through the shared outside-pointer hook The turn-stat dialogs copied ContextMeter's document-listener effect, which the duplication gate flags; useDismissOnOutsidePointer gains an optional portal ref so the portaled dialog counts as inside, and TurnUsagePanel keeps only the Escape listener. The icon-count test also learns the two pill glyphs the branch added. --- .../src/client/chat/TurnUsagePanel.tsx | 18 +++----- .../src/useDismissOnOutsidePointer.ts | 9 +++- .../ui-primitives/tests/icons.client.spec.tsx | 4 +- ...dismiss-on-outside-pointer.client.spec.tsx | 41 +++++++++++++++++++ 4 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index a8ab03f6ab..b3cd2c12a7 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react' import { createPortal } from 'react-dom' import { - IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, + IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTokenUsage } from '../contract/chat-nodes.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -76,24 +76,16 @@ function useStatDialog(): StatDialogSeat { margin: PANEL_MARGIN, }) - // Outside click / Escape close, one document listener while open (ContextMeter's pattern). + // Outside pointerdown closes through the shared primitive; the portaled + // panel counts as inside. Escape close stays local, one listener while open. + useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) useEffect(() => { if (!open) return - const onPointerDown = (e: PointerEvent): void => { - if (!(e.target instanceof Node)) return - if (rootRef.current?.contains(e.target) === true) return - if (panelRef.current?.contains(e.target) === true) return - setOpen(false) - } const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false) } - document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) - return () => { - document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) - } + return () => { document.removeEventListener('keydown', onKeyDown) } }, [open]) return { open, setOpen, rootRef, panelRef, pos } diff --git a/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts b/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts index 3706d13bfc..2f16f43476 100644 --- a/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts +++ b/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts @@ -10,20 +10,25 @@ import type { RefObject } from 'react' * @param root - element containing both the trigger and the open surface. * @param open - whether the surface is showing; false detaches the listener. * @param setOpen - state setter invoked with false on an outside pointerdown. + * @param portal - surface portaled outside the root (a `document.body` dialog) + * that also counts as inside; omit when the root contains the whole popover. */ export function useDismissOnOutsidePointer( root: RefObject, open: boolean, setOpen: (open: boolean) => void, + portal?: RefObject, ): void { useEffect(() => { if (!open) return const closeOutside = (event: PointerEvent): void => { - if (event.target instanceof Node && !root.current?.contains(event.target)) { + if (event.target instanceof Node + && root.current?.contains(event.target) !== true + && portal?.current?.contains(event.target) !== true) { setOpen(false) } } document.addEventListener('pointerdown', closeOutside) return () => { document.removeEventListener('pointerdown', closeOutside) } - }, [root, open, setOpen]) + }, [root, open, setOpen, portal]) } diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 5e4d7dd2a4..acb3e45503 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 21 figma extracts + four product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(71) + it('exports the full icon set (46 deepsuite + 21 figma extracts + six product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(73) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx b/packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx new file mode 100644 index 0000000000..e038bcb80d --- /dev/null +++ b/packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +/** The outside-pointer dismissal primitive as observable popover behavior. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +function Popover({ portaled }: { portaled: boolean }) { + const [open, setOpen] = useState(true) + const rootRef = useRef(null) + const panelRef = useRef(null) + useDismissOnOutsidePointer(rootRef, open, setOpen, portaled ? panelRef : undefined) + return ( +
+ + {open && !portaled &&
surface
} + {open && portaled && createPortal(
surface
, document.body)} +
+ ) +} + +describe('useDismissOnOutsidePointer', () => { + it('closes on an outside pointerdown but not on one inside the root', () => { + const view = render() + fireEvent.pointerDown(view.getByTestId('root')) + expect(view.queryByTestId('surface')).not.toBeNull() + fireEvent.pointerDown(document.body) + expect(view.queryByTestId('surface')).toBeNull() + }) + + it('counts the portaled surface as inside while still closing outside it', () => { + const view = render() + fireEvent.pointerDown(view.getByTestId('surface')) + expect(view.queryByTestId('surface')).not.toBeNull() + fireEvent.pointerDown(document.body) + expect(view.queryByTestId('surface')).toBeNull() + }) +}) From 66d0bbd5b510721f0096251159a46a881962c76c Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 12:50:25 +0800 Subject: [PATCH 030/429] test(web): align e2e suite with the hero-glow removal and menu retention conversation-column-overflow existed solely to verify the glow bleed was clipped; delete it with its golden. The geometry golden picks up the scroll body losing overflow-x: hidden. reference-composer now waits for the stale '@' rows to settle before clicking: the menu keeps the previous query's rows while the next loads, and index-keyed rows swap content in place. --- .../tests/conversation-column-overflow.e2e.ts | 344 ------------------ .../geometry.expected.md | 6 +- .../geometry.expected.md | 9 - apps/web/tests/reference-composer.e2e.ts | 6 + 4 files changed, 9 insertions(+), 356 deletions(-) delete mode 100644 apps/web/tests/conversation-column-overflow.e2e.ts delete mode 100644 apps/web/tests/expected/conversation-column-overflow/geometry.expected.md diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts deleted file mode 100644 index 460acf9163..0000000000 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Web e2e scenario: the conversation column scrolls on one axis only, as the -// browser actually lays it out. The hazard: a horizontal scrollbar appears -// under the whole center column once the window (or the sidebar drag) narrows -// it — the hero's decorative backdrop ellipse bleeds past the column and -// becomes user-scrollable. -// -// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the -// hero box (ConversationRoot.module.css) so the blur scales with the input -// card. The scroll container is where the bar comes from: -// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller -// computes the other axis's initial `visible` to `auto`, so the bleed becomes -// a bar; `overflow-x: hidden` on the scroller prevents it. -// -// Only a real engine reports that pair — the bleed and the resulting scroll -// range — so the scenario sweeps viewport widths that bracket the glow's -// width and asserts both at each stop. Asserting no horizontal scroll alone -// would go vacuous the moment the glow stopped bleeding for an unrelated -// reason, which is why each stop also records whether it bleeds; the wide stop -// is the control where it does not. -// -// Zero model calls: the hero is the boot state, so nothing is seeded and no -// replay row mounts. A stray stream would fail loud with NO_ADAPTER. -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { - assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, - type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/conversation-column-overflow', import.meta.url)) -/** - * Committed golden of the one-axis relation at every stop. It records - * relations and booleans, never absolute coordinates: the column width follows - * the viewport and the sidebar, and a golden carrying pixels would document the - * platform instead of the behavior. - */ -const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') -const MODE = webSnapshotMode() -/** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */ -const CONTROL_VIEWPORT = 600 -/** - * Viewport widths bracketing the glow: the narrow stops retain the reported - * bleed while the widest stop proves the relation can also be false. - */ -const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT] -/** Element id of the mutation control's injected sheet, so the test can take it back out. */ -const CONTROL_STYLE_ID = 'dsh-column-overflow-control' -/** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */ -const WHEEL_DELTA = 300 - -/** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */ -interface ColumnMetrics { - /** Viewport width the stop was measured at. */ - width: number - /** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */ - columnWidth: number - /** Resolved `overflow-x` on the conversation scroll container. */ - overflowX: string - /** - * True when the glow's box reaches past the column's content edge — the - * condition the `overflow-x: hidden` declaration has to survive. - */ - glowBleeds: boolean - /** - * `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and - * `auto` both report the same value, because `hidden` clips the bleed rather - * than reflowing it away. Recorded because it is the vacuity guard in - * numbers — it must stay positive at the narrow stops, or the scenario has - * stopped reproducing the situation `overflow-x: hidden` exists for. - */ - bleedRange: number - /** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */ - scrollsVertically: boolean -} - -/** - * Measure the conversation column at the page's current viewport. - * @param page - the page under test. - * @param width - the viewport width already applied, recorded with the reading. - * @returns the stop's overflow relations. - */ -function measureColumn(page: Page, width: number): Promise { - return page.evaluate((viewportWidth) => { - const scroller = document.querySelector('[data-conversation-scroll]') - if (scroller === null) throw new Error('conversation scroll container not in the DOM') - const glow = scroller.querySelector('[class*="heroGlow"]') - if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero') - const box = scroller.getBoundingClientRect() - const glowBox = glow.getBoundingClientRect() - return { - width: viewportWidth, - columnWidth: scroller.clientWidth, - overflowX: getComputedStyle(scroller).overflowX, - // `clientWidth` is the content edge, which is what the scrollable - // overflow region is measured against; either side counts as a bleed, - // though only the right one can produce a bar in this writing mode. - glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5, - bleedRange: scroller.scrollWidth - scroller.clientWidth, - scrollsVertically: getComputedStyle(scroller).overflowY === 'auto', - } - }, width) -} - -/** - * Scroll the column sideways the way a user would and report where it landed. - * - * This is the one signal that separates the two states, and it is why the - * scenario needs a real engine: `overflow-x: hidden` leaves the box - * programmatically scrollable and leaves `scrollWidth` untouched, so every - * property reading agrees across the two overflow modes. Only refusing an - * actual input event differs — measured at the 1200px stop, the shipped - * column stays at 0 while the same page with `overflow-x: auto` forced on - * lands at its scroll boundary. - * @param page - the page under test. - * @returns `scrollLeft` after one horizontal wheel over the column. - */ -async function wheelHorizontally(page: Page): Promise { - const origin = await page.evaluate(() => { - const scroller = document.querySelector('[data-conversation-scroll]') - if (scroller === null) throw new Error('conversation scroll container not in the DOM') - // Start from the origin so the reading is this gesture's own effect. - scroller.scrollLeft = 0 - const box = scroller.getBoundingClientRect() - // Near the top of the column, clear of the centered hero card: the wheel - // must reach the column, not a nested scroller the composer owns. - return { x: box.left + box.width / 2, y: box.top + 60 } - }) - await page.mouse.move(origin.x, origin.y) - await page.mouse.wheel(WHEEL_DELTA, 0) - // A fixed settle, then two frames. Polling for a settled value cannot be - // used here — the value under test is 0, which a poll starting at 0 accepts - // before the gesture has had any chance to move it — so the wait is - // generous enough to cover a smooth-scroll animation on any engine the lane - // runs on. The timing is identical on both sides of the mutation control - // below, which is what makes a 0 reading evidence rather than a race won. - await page.waitForTimeout(400) - return page.evaluate(() => new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve(document.querySelector('[data-conversation-scroll]')?.scrollLeft ?? -1) - }) - }) - })) -} - -/** - * Measure the positive horizontal scroll boundary without changing the - * shipped overflow mode. This is distinct from `scrollWidth - clientWidth` - * when a stable scrollbar gutter leaves part of the overflow on the negative - * side of the scroll origin. - * @param page - the page under test. - * @returns the greatest positive `scrollLeft` reachable by the control gesture. - */ -async function horizontalScrollLimit(page: Page): Promise { - return page.evaluate((delta) => { - const scroller = document.querySelector('[data-conversation-scroll]') - if (scroller === null) throw new Error('conversation scroll container not in the DOM') - const previousScrollBehavior = scroller.style.scrollBehavior - scroller.style.scrollBehavior = 'auto' - scroller.scrollLeft = delta - const limit = scroller.scrollLeft - scroller.scrollLeft = 0 - scroller.style.scrollBehavior = previousScrollBehavior - return limit - }, WHEEL_DELTA) -} - -/** A stop's readings plus where a horizontal wheel over it landed. */ -type ColumnStop = ColumnMetrics & { - /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */ - scrollLeftAfterWheel: number -} - -/** - * Render the golden body: one line per stop, relations only. - * - * Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`, - * which the shipped overflow mode pins to 0 by construction. The bleed is - * recorded as a boolean rather than its width, so the golden survives any - * platform whose column lands a pixel off — a fixture that has to be - * re-recorded per platform documents the platform, not the behavior. - * @param stops - the measured stops, in sweep order. - * @returns the golden body, without a trailing newline. - */ -function renderGeometry(stops: ColumnStop[]): string { - return [ - '# Conversation column horizontal overflow', - '', - '| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |', - '| --- | --- | --- | --- | --- |', - ...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} ` - + `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`), - ].join('\n') -} - -describe('web e2e: the conversation column scrolls on one axis', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - browser = await chromium.launch() - page = await newEnglishPage(browser, 900) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 }) - }, 180_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - /** - * Resize to a viewport and read the column once its width stops moving. - * - * The glow rides the hero box, which rides the column, and the frame eases - * its column tracks over `--ds-transition-duration-slow`: reading straight - * after a resize can report the previous viewport's relation, or a width - * caught mid-transition. - * @param width - viewport width to settle at. - * @returns the column's readings at that width. - */ - const settleAt = async (width: number): Promise => { - await page.setViewportSize({ width, height: 900 }) - let previous = -1 - await expect.poll(async () => { - const current = (await measureColumn(page, width)).columnWidth - const settled = current === previous - previous = current - return settled - }, { timeout: 10_000 }).toBe(true) - return measureColumn(page, width) - } - - /** - * Sweep the stops once per run and hand the SAME readings to every assertion - * below, so the golden and the assertions describe one measurement instead of - * two runs that could disagree. Memoized rather than re-run per test: the - * gestures below move the viewport, and a second sweep would be a second - * chance for a resize to settle differently. - * @returns the stops in {@link WIDTHS} order. - */ - let swept: Promise | undefined - const sweep = (): Promise => { - swept ??= (async () => { - const stops: ColumnStop[] = [] - for (const width of WIDTHS) { - stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) }) - } - return stops - })() - return swept - } - - it('never scrolls horizontally, at any width the glow bleeds past', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow')) - const stops = await sweep() - // The vacuity guard, in two halves: the glow has to reach past the column - // at the narrow stops, and that reach has to still register as scrollable - // overflow. Without both, the claim below holds for free. - expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([ - 1200, 1000, 800, CONTROL_VIEWPORT, - ]) - for (const stop of stops.filter(stop => stop.glowBleeds)) { - expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0) - } - for (const stop of stops) { - expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden') - // The reported symptom, stated directly: a horizontal wheel over the - // column moves nothing, at every stop. - expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0) - // The axis the column is a scroller for must survive `overflow-x: hidden`. - expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true) - } - expect(tripwire.pageErrors).toEqual([]) - }, 120_000) - - it('scrolls horizontally again once the axis is opened back up (control)', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control')) - // The mutation control, run in the page rather than against a second - // build: it lifts exactly the `overflow-x: hidden` declaration, so the - // initial `visible` that a one-axis scroller computes to `auto` takes - // over, and shows the same gesture, at the same timing, carrying the - // column to its positive scroll boundary. - // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived. - // Injected with an id rather than through `addStyleTag`, so the teardown - // below can take the sheet out again by selector: it must not outlive this - // test, or the golden ends up reading the control. - await page.evaluate((id: string) => { - const sheet = document.createElement('style') - sheet.id = id - sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }' - document.head.append(sheet) - }, CONTROL_STYLE_ID) - try { - // Resolve the mutated layout at the narrowest sweep stop. At wider stops, - // a classic scrollbar can change the available box enough to remove the - // overflow that the control is meant to expose. - const before = await settleAt(CONTROL_VIEWPORT) - expect(before.overflowX).toBe('auto') - expect(before.bleedRange).toBeGreaterThan(0) - const scrollLimit = await horizontalScrollLimit(page) - // The control has a reachable horizontal range, and the gesture exceeds - // it so the equality below proves that the wheel reached the far edge. - expect(scrollLimit).toBeGreaterThan(0) - expect(scrollLimit).toBeLessThan(WHEEL_DELTA) - // Rounded: `scrollLeft` is fractional under a fractional layout while - // the claim is that the column reached the positive boundary, not that - // two engines agree on a sub-pixel. - expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit)) - } finally { - await page.evaluate((id: string) => { - document.getElementById(id)?.remove() - }, CONTROL_STYLE_ID) - } - // The override is gone and the shipped state is back: the later goldens - // read the product, not the control. - expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden') - expect(tripwire.pageErrors).toEqual([]) - }, 120_000) - - it('matches the committed column-overflow golden', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden')) - await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) - expect(tripwire.pageErrors).toEqual([]) - }, 120_000) - - it('commits exactly the fixtures it reads', async () => { - // No model calls, so no replay log: the golden is the whole inventory. - await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) - }) - - it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { - expect(tripwire.warnings).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - }) -}) diff --git a/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md b/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md index e95adaaa6f..19a61c0c94 100644 --- a/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md +++ b/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md @@ -2,7 +2,7 @@ ## Wide viewport (1680px, card at its cap) -- Chat: scrollbar-gutter stable, overflow hidden/auto +- Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter auto, overflow hidden/auto @@ -14,7 +14,7 @@ ## Narrow viewport (800px, card shrinking with the column) -- Chat: scrollbar-gutter stable, overflow hidden/auto +- Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter auto, overflow hidden/auto @@ -26,7 +26,7 @@ ## Wide viewport, seat compensation removed in the page (control) -- Chat: scrollbar-gutter stable, overflow hidden/auto +- Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter auto, overflow hidden/auto diff --git a/apps/web/tests/expected/conversation-column-overflow/geometry.expected.md b/apps/web/tests/expected/conversation-column-overflow/geometry.expected.md deleted file mode 100644 index f9c807b43e..0000000000 --- a/apps/web/tests/expected/conversation-column-overflow/geometry.expected.md +++ /dev/null @@ -1,9 +0,0 @@ -# Conversation column horizontal overflow - -| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically | -| --- | --- | --- | --- | --- | -| 1680px | hidden | false | 0px | true | -| 1200px | hidden | true | 0px | true | -| 1000px | hidden | true | 0px | true | -| 800px | hidden | true | 0px | true | -| 600px | hidden | true | 0px | true | diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ad7e5410da..95e82385da 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -166,6 +166,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through expect(snapshot).not.toContain('text: Subagents') await input.fill('@reference') + // The open menu keeps the previous query's rows while the new one loads + // (stale-while-revalidate), and rows are keyed by index, so a click + // resolved against a stale row lands on whatever settles into that slot. + // `folderx/` matches only the bare '@' query: its disappearance marks the + // settled result set. + await expect.poll(() => menu.getByRole('option', { name: /folderx/ }).count(), { timeout: 15_000 }).toBe(0) await menu.getByRole('option', { name: /reference\.txt/ }).click() // The pick lands an atomic chip: a real DOM capsule carrying the domain // icon and the label (the canonical reference text lives on the node and From 15f2997bcb435ebb4873c0490f713b2bb8cd1ce9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 28 Aug 2026 12:37:29 +0800 Subject: [PATCH 031/429] cleanup: omit unneeded invariant companions --- ...ev-invariants-over-deep-readonly.i18n.yaml | 4 +- ...06-11-dev-invariants-over-deep-readonly.md | 2 +- ...11-dev-invariants-over-deep-readonly.zh.md | 2 +- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 22 +- ...-package-invariant-runtime-contracts.zh.md | 22 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 16 +- ...7-19-package-owned-invariant-service.zh.md | 16 +- ...2026-08-10-remote-event-delivery.i18n.yaml | 4 +- .../2026-08-10-remote-event-delivery.md | 2 +- .../2026-08-10-remote-event-delivery.zh.md | 2 +- ...ution-realms-and-protocol-planes.i18n.yaml | 4 +- ...or-execution-realms-and-protocol-planes.md | 2 +- ...execution-realms-and-protocol-planes.zh.md | 2 +- ...it-unneeded-invariant-companions.i18n.yaml | 6 + ...8-28-omit-unneeded-invariant-companions.md | 48 ++ ...8-omit-unneeded-invariant-companions.zh.md | 48 ++ .agents/skills/dsh-code-review/SKILL.md | 2 +- .../skills/dsh-find-simplifications/SKILL.md | 4 + AGENTS.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 622 ++++++------------ docs/module-graph.zh.md | 622 ++++++------------ packages/AGENTS.md | 2 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 2 +- packages/acp/acp/README.zh.md | 2 +- packages/acp/acp/package.json | 7 - packages/acp/acp/src/invariant.ts | 30 - packages/acp/acp/tsconfig.json | 3 - packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 + packages/api/gateway/README.zh.md | 2 + packages/api/gateway/package.json | 7 - packages/api/gateway/src/invariant.ts | 31 - packages/api/gateway/tsconfig.host.json | 4 - packages/api/gateway/tsdown.config.ts | 2 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + packages/api/remotes/package.json | 7 - packages/api/remotes/src/invariant.ts | 24 - packages/api/remotes/tsconfig.host.json | 4 - packages/api/remotes/tsdown.config.ts | 2 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + packages/api/session-controller/package.json | 7 - .../api/session-controller/src/invariant.ts | 20 - .../api/session-controller/tsconfig.host.json | 2 - .../api/session-controller/tsdown.config.ts | 2 +- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 2 + packages/api/settings-controller/README.zh.md | 2 + packages/api/settings-controller/package.json | 7 - .../api/settings-controller/src/invariant.ts | 23 - .../tests/invariant.spec.ts | 16 - .../api/settings-controller/tsconfig.json | 3 - .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 2 + .../api/workspace-controller/README.zh.md | 2 + .../api/workspace-controller/package.json | 7 - .../api/workspace-controller/src/invariant.ts | 20 - .../workspace-controller/tsconfig.host.json | 2 - .../api/workspace-controller/tsdown.config.ts | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/package.json | 5 +- .../attachment-local/src/invariant.ts | 20 - .../attachment/attachment-local/tsconfig.json | 4 +- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/package.json | 5 +- .../attachment/attachment/src/invariant.ts | 20 - packages/attachment/attachment/tsconfig.json | 3 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/package.json | 7 - packages/boot/app-boot/src/invariant.ts | 30 - packages/boot/app-boot/tsconfig.json | 3 - packages/boot/app-boot/tsdown.config.ts | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/package.json | 7 - packages/boot/cmdline/src/invariant.ts | 30 - packages/boot/cmdline/tsconfig.json | 3 - packages/bundle/acp-app/README.i18n.yaml | 4 +- packages/bundle/acp-app/README.md | 2 + packages/bundle/acp-app/README.zh.md | 2 + packages/bundle/acp-app/package.json | 7 - packages/bundle/acp-app/src/invariant.ts | 28 - packages/bundle/acp-app/tsconfig.json | 3 - packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 4 +- packages/bundle/base/README.zh.md | 4 +- packages/bundle/base/package.json | 7 - packages/bundle/base/src/invariant.ts | 28 - packages/bundle/base/tsconfig.json | 3 - packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/package.json | 7 - packages/bundle/headless/src/invariant.ts | 30 - packages/bundle/headless/tsconfig.json | 3 - packages/bundle/sdk-app/README.i18n.yaml | 4 +- packages/bundle/sdk-app/README.md | 2 + packages/bundle/sdk-app/README.zh.md | 2 + packages/bundle/sdk-app/package.json | 7 - packages/bundle/sdk-app/src/invariant.ts | 28 - packages/bundle/sdk-app/tsconfig.json | 3 - packages/bundle/sdk-minimal/README.i18n.yaml | 4 +- packages/bundle/sdk-minimal/README.md | 2 +- packages/bundle/sdk-minimal/README.zh.md | 2 +- packages/bundle/sdk-minimal/package.json | 7 - packages/bundle/sdk-minimal/src/invariant.ts | 26 - packages/bundle/sdk-minimal/tsconfig.json | 3 - packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 4 +- packages/bundle/web-app/README.zh.md | 4 +- packages/bundle/web-app/package.json | 7 - packages/bundle/web-app/src/invariant.ts | 30 - packages/bundle/web-app/tsconfig.json | 3 - packages/client/AGENTS.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 + packages/client/connection/README.zh.md | 2 + packages/client/connection/package.json | 7 - packages/client/connection/src/invariant.ts | 34 - .../client/connection/tsconfig.client.json | 3 - packages/client/connection/tsconfig.host.json | 10 +- packages/client/connection/tsdown.config.ts | 2 +- packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 + packages/client/locale/README.zh.md | 2 + packages/client/locale/package.json | 7 - packages/client/locale/src/invariant.ts | 31 - .../locale/tests/invariant.client.spec.ts | 38 -- packages/client/locale/tsconfig.json | 6 +- packages/client/locale/tsdown.config.ts | 2 +- packages/client/store/README.i18n.yaml | 4 +- packages/client/store/README.md | 2 + packages/client/store/README.zh.md | 2 + packages/client/store/package.json | 11 +- packages/client/store/src/invariant.ts | 30 - .../store/tests/invariant.client.spec.ts | 13 - packages/client/store/tsconfig.json | 3 - packages/client/store/tsdown.config.ts | 2 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + packages/client/ui-agent-preset/package.json | 7 - .../client/ui-agent-preset/src/invariant.ts | 30 - .../tests/apply.client.spec.ts | 5 + .../tests/invariant.client.spec.ts | 25 - packages/client/ui-agent-preset/tsconfig.json | 3 - .../client/ui-agent-preset/tsdown.config.ts | 2 +- packages/client/ui-approval/README.i18n.yaml | 4 +- packages/client/ui-approval/README.md | 2 + packages/client/ui-approval/README.zh.md | 2 + packages/client/ui-approval/package.json | 7 - packages/client/ui-approval/src/invariant.ts | 23 - .../tests/ui-approval.client.spec.tsx | 8 +- packages/client/ui-approval/tsconfig.json | 3 - packages/client/ui-approval/tsdown.config.ts | 2 +- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 + packages/client/ui-attachment/README.zh.md | 2 + packages/client/ui-attachment/package.json | 7 - .../client/ui-attachment/src/invariant.ts | 30 - .../tests/invariant.client.spec.ts | 12 - packages/client/ui-attachment/tsconfig.json | 4 +- .../client/ui-attachment/tsdown.config.ts | 2 +- .../client/ui-brand-official/README.i18n.yaml | 4 +- packages/client/ui-brand-official/README.md | 2 + .../client/ui-brand-official/README.zh.md | 2 + .../client/ui-brand-official/package.json | 7 - .../client/ui-brand-official/src/invariant.ts | 30 - .../tests/browser-plugin.client.spec.tsx | 5 + .../tests/invariant.client.spec.ts | 18 - .../client/ui-brand-official/tsconfig.json | 2 +- .../client/ui-brand-official/tsdown.config.ts | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + packages/client/ui-chat/package.json | 7 - packages/client/ui-chat/src/invariant.ts | 21 - .../tests/approval-command.client.spec.tsx | 7 +- packages/client/ui-chat/tsconfig.json | 6 +- packages/client/ui-chat/tsdown.config.ts | 2 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 + packages/client/ui-commands/README.zh.md | 2 + packages/client/ui-commands/package.json | 7 - packages/client/ui-commands/src/invariant.ts | 31 - packages/client/ui-commands/tsconfig.json | 3 - packages/client/ui-commands/tsdown.config.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + packages/client/ui-conversation/package.json | 7 - .../client/ui-conversation/src/invariant.ts | 30 - packages/client/ui-conversation/tsconfig.json | 6 +- .../client/ui-conversation/tsdown.config.ts | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 + packages/client/ui-deliverables/README.zh.md | 2 + packages/client/ui-deliverables/package.json | 7 - .../client/ui-deliverables/src/invariant.ts | 31 - .../tests/produced-files.client.spec.tsx | 14 - packages/client/ui-deliverables/tsconfig.json | 3 - .../client/ui-deliverables/tsdown.config.ts | 2 +- .../README.i18n.yaml | 4 +- .../ui-directory-picker-browse/README.md | 2 + .../ui-directory-picker-browse/README.zh.md | 2 + .../ui-directory-picker-browse/package.json | 7 - .../src/invariant.ts | 31 - .../tests/client-flow.client.spec.tsx | 2 - .../ui-directory-picker-browse/tsconfig.json | 2 +- .../tsdown.config.ts | 2 +- .../README.i18n.yaml | 4 +- .../ui-directory-picker-native/README.md | 2 + .../ui-directory-picker-native/README.zh.md | 2 + .../ui-directory-picker-native/package.json | 7 - .../src/invariant.ts | 31 - .../tests/client-flow.client.spec.tsx | 2 - .../ui-directory-picker-native/tsconfig.json | 2 +- .../tsdown.config.ts | 2 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 + packages/client/ui-goal/README.zh.md | 2 + packages/client/ui-goal/package.json | 7 - packages/client/ui-goal/src/invariant.ts | 32 - .../tests/browser-plugin.client.spec.tsx | 5 +- packages/client/ui-goal/tsconfig.json | 3 - packages/client/ui-goal/tsdown.config.ts | 2 +- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 2 + packages/client/ui-input-trigger/README.zh.md | 2 + packages/client/ui-input-trigger/package.json | 7 - .../client/ui-input-trigger/src/invariant.ts | 32 - .../client/ui-input-trigger/tsconfig.json | 3 - .../client/ui-input-trigger/tsdown.config.ts | 2 +- 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-jobs/package.json | 7 - packages/client/ui-jobs/src/invariant.ts | 32 - .../tests/browser-plugin.client.spec.ts | 21 +- packages/client/ui-jobs/tsconfig.json | 3 - packages/client/ui-jobs/tsdown.config.ts | 2 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 + packages/client/ui-layout/README.zh.md | 2 + packages/client/ui-layout/package.json | 7 - packages/client/ui-layout/src/invariant.ts | 31 - .../ui-layout/tests/apply.client.spec.ts | 15 +- packages/client/ui-layout/tsconfig.json | 3 - packages/client/ui-layout/tsdown.config.ts | 2 +- .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 2 + .../client/ui-message-feedback/README.zh.md | 2 + .../client/ui-message-feedback/package.json | 7 - .../ui-message-feedback/src/invariant.ts | 33 - .../tests/browser-plugin.client.spec.tsx | 5 +- .../client/ui-message-feedback/tsconfig.json | 3 - .../ui-message-feedback/tsdown.config.ts | 2 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 + .../client/ui-model-selection/README.zh.md | 2 + .../client/ui-model-selection/package.json | 7 - .../ui-model-selection/src/invariant.ts | 31 - .../client/ui-model-selection/tsconfig.json | 3 - .../ui-model-selection/tsdown.config.ts | 2 +- .../ui-permission-presets/README.i18n.yaml | 4 +- .../client/ui-permission-presets/README.md | 2 + .../client/ui-permission-presets/README.zh.md | 2 + .../client/ui-permission-presets/package.json | 7 - .../ui-permission-presets/src/invariant.ts | 31 - .../ui-permission-presets/tsconfig.json | 3 - .../ui-permission-presets/tsdown.config.ts | 2 +- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 + packages/client/ui-plan/README.zh.md | 2 + packages/client/ui-plan/package.json | 7 - packages/client/ui-plan/src/invariant.ts | 31 - packages/client/ui-plan/tsconfig.json | 3 - packages/client/ui-plan/tsdown.config.ts | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + packages/client/ui-primitives/package.json | 7 - .../client/ui-primitives/src/invariant.ts | 31 - .../tests/invariant.client.spec.ts | 12 - packages/client/ui-primitives/tsconfig.json | 5 - .../client/ui-primitives/tsdown.config.ts | 2 +- packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 2 + packages/client/ui-reference/README.zh.md | 2 + packages/client/ui-reference/package.json | 7 - packages/client/ui-reference/src/invariant.ts | 31 - packages/client/ui-reference/tsconfig.json | 3 - packages/client/ui-reference/tsdown.config.ts | 2 +- packages/client/ui-session/README.i18n.yaml | 4 +- packages/client/ui-session/README.md | 2 + packages/client/ui-session/README.zh.md | 2 + packages/client/ui-session/package.json | 7 - packages/client/ui-session/src/invariant.ts | 21 - .../tests/ui-session.client.spec.ts | 8 +- packages/client/ui-session/tsconfig.json | 3 - packages/client/ui-session/tsdown.config.ts | 2 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../client/ui-settings-general/package.json | 7 - .../ui-settings-general/src/invariant.ts | 32 - .../tests/invariant.client.spec.ts | 12 - .../client/ui-settings-general/tsconfig.json | 6 +- .../ui-settings-general/tsdown.config.ts | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../client/ui-settings-models/package.json | 7 - .../ui-settings-models/src/invariant.ts | 31 - .../tests/apply.client.spec.ts | 5 + .../tests/invariant.client.spec.ts | 24 - .../client/ui-settings-models/tsconfig.json | 3 - .../ui-settings-models/tsdown.config.ts | 2 +- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 2 + .../ui-settings-plugin-inventory/README.zh.md | 2 + .../ui-settings-plugin-inventory/package.json | 7 - .../src/invariant.ts | 20 - .../tests/browser-plugin.client.spec.tsx | 5 + .../tests/invariant.client.spec.ts | 15 - .../tsconfig.json | 3 - .../tsdown.config.ts | 2 +- .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 2 + .../client/ui-settings-plugins/README.zh.md | 2 + .../client/ui-settings-plugins/package.json | 7 - .../ui-settings-plugins/src/invariant.ts | 31 - .../tests/apply.client.spec.ts | 5 + .../tests/invariant.client.spec.ts | 25 - .../client/ui-settings-plugins/tsconfig.json | 3 - .../ui-settings-plugins/tsdown.config.ts | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 + packages/client/ui-settings/README.zh.md | 2 + packages/client/ui-settings/package.json | 7 - packages/client/ui-settings/src/invariant.ts | 32 - .../tests/invariant.client.spec.ts | 18 - .../ui-settings/tests/plugin.client.spec.ts | 5 + packages/client/ui-settings/tsconfig.json | 3 - packages/client/ui-settings/tsdown.config.ts | 2 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 + packages/client/ui-sidebar/README.zh.md | 2 + packages/client/ui-sidebar/package.json | 7 - packages/client/ui-sidebar/src/invariant.ts | 32 - .../ui-sidebar/tests/apply.client.spec.tsx | 5 + .../ui-sidebar/tests/invariant.client.spec.ts | 18 - packages/client/ui-sidebar/tsconfig.json | 3 - packages/client/ui-sidebar/tsdown.config.ts | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 + packages/client/ui-skill/README.zh.md | 2 + packages/client/ui-skill/package.json | 7 - packages/client/ui-skill/src/invariant.ts | 32 - packages/client/ui-skill/tsconfig.json | 3 - packages/client/ui-skill/tsdown.config.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 + packages/client/ui-slots/package.json | 7 - packages/client/ui-slots/src/invariant.ts | 32 - .../ui-slots/tests/invariant.client.spec.ts | 12 - packages/client/ui-slots/tsconfig.json | 4 +- packages/client/ui-slots/tsdown.config.ts | 2 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 + packages/client/ui-subagent/README.zh.md | 2 + packages/client/ui-subagent/package.json | 7 - packages/client/ui-subagent/src/invariant.ts | 31 - packages/client/ui-subagent/tsconfig.json | 3 - packages/client/ui-subagent/tsdown.config.ts | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 + packages/client/ui-theme/README.zh.md | 2 + packages/client/ui-theme/package.json | 7 - packages/client/ui-theme/src/invariant.ts | 32 - .../ui-theme/tests/invariant.client.spec.ts | 44 -- packages/client/ui-theme/tsconfig.json | 6 +- packages/client/ui-theme/tsdown.config.ts | 2 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 + packages/client/ui-tool/README.zh.md | 2 + packages/client/ui-tool/package.json | 7 - packages/client/ui-tool/src/invariant.ts | 30 - packages/client/ui-tool/tsconfig.json | 3 - packages/client/ui-tool/tsdown.config.ts | 2 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 2 + packages/client/ui-trajectory/package.json | 7 - .../client/ui-trajectory/src/invariant.ts | 32 - packages/client/ui-trajectory/tsconfig.json | 3 - .../client/ui-trajectory/tsdown.config.ts | 2 +- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 + .../client/ui-user-questions/README.zh.md | 2 + .../client/ui-user-questions/package.json | 7 - .../client/ui-user-questions/src/invariant.ts | 31 - .../client/ui-user-questions/tsconfig.json | 3 - .../client/ui-user-questions/tsdown.config.ts | 2 +- .../client/ui-workflow-run/README.i18n.yaml | 4 +- packages/client/ui-workflow-run/README.md | 2 + packages/client/ui-workflow-run/README.zh.md | 2 + packages/client/ui-workflow-run/package.json | 7 - .../client/ui-workflow-run/src/invariant.ts | 24 - .../tests/workflow-run.client.spec.tsx | 11 +- packages/client/ui-workflow-run/tsconfig.json | 3 - .../client/ui-workflow-run/tsdown.config.ts | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + packages/client/ui-workspace/package.json | 7 - packages/client/ui-workspace/src/invariant.ts | 32 - .../ui-workspace/tests/apply.client.spec.ts | 5 + .../tests/invariant.client.spec.ts | 18 - packages/client/ui-workspace/tsconfig.json | 3 - packages/client/ui-workspace/tsdown.config.ts | 2 +- packages/client/web/README.i18n.yaml | 4 +- packages/client/web/README.md | 2 + packages/client/web/README.zh.md | 2 + packages/client/web/package.json | 7 - packages/client/web/src/invariant.ts | 32 - packages/client/web/tsconfig.json | 3 - packages/client/web/tsdown.config.ts | 2 +- .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../code-runtime-python/package.json | 7 - .../code-runtime-python/src/invariant.ts | 31 - .../code-runtime-python/tsconfig.json | 3 - .../code-runtime-python/tsdown.config.ts | 2 +- .../README.i18n.yaml | 4 +- .../code-runtime-worker-thread/README.md | 2 +- .../code-runtime-worker-thread/README.zh.md | 2 +- .../code-runtime-worker-thread/package.json | 7 - .../src/invariant.ts | 30 - .../code-runtime-worker-thread/tsconfig.json | 3 - .../tsdown.config.ts | 2 +- .../code-runtime/README.i18n.yaml | 4 +- packages/code-runtime/code-runtime/README.md | 2 +- .../code-runtime/code-runtime/README.zh.md | 2 +- .../code-runtime/code-runtime/package.json | 7 - .../code-runtime/src/invariant.ts | 30 - .../code-runtime/code-runtime/tsconfig.json | 3 - .../command-compact/README.i18n.yaml | 4 +- packages/compaction/command-compact/README.md | 2 +- .../compaction/command-compact/README.zh.md | 2 +- .../compaction/command-compact/package.json | 7 - .../command-compact/src/invariant.ts | 30 - .../command-compact/tests/invariant.spec.ts | 18 - .../compaction/command-compact/tsconfig.json | 3 - .../compaction-basic/README.i18n.yaml | 4 +- .../compaction/compaction-basic/README.md | 2 +- .../compaction/compaction-basic/README.zh.md | 2 +- .../compaction/compaction-basic/package.json | 6 - .../compaction-basic/src/invariant.ts | 30 - .../tests/manual-compaction.spec.ts | 2 - .../compaction/compaction-basic/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../compaction-tool-result-pruner/README.md | 2 +- .../README.zh.md | 2 +- .../package.json | 6 - .../src/invariant.ts | 27 - .../tsconfig.json | 3 +- .../agent-instructions/README.i18n.yaml | 4 +- packages/context/agent-instructions/README.md | 2 +- .../context/agent-instructions/README.zh.md | 2 +- .../context/agent-instructions/package.json | 7 - .../agent-instructions/src/invariant.ts | 30 - .../context/agent-instructions/tsconfig.json | 3 - .../file-reference-local/README.i18n.yaml | 4 +- .../context/file-reference-local/README.md | 2 +- .../context/file-reference-local/README.zh.md | 2 +- .../context/file-reference-local/package.json | 7 - .../file-reference-local/src/invariant.ts | 30 - .../tests/invariant.spec.ts | 12 - .../file-reference-local/tsconfig.json | 3 - .../context/file-reference/README.i18n.yaml | 4 +- packages/context/file-reference/README.md | 2 +- packages/context/file-reference/README.zh.md | 2 +- packages/context/file-reference/package.json | 7 - .../context/file-reference/src/invariant.ts | 30 - .../file-reference/tests/invariant.spec.ts | 12 - packages/context/file-reference/tsconfig.json | 3 - .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/session-reference/package.json | 7 - .../session-reference/src/invariant.ts | 30 - .../context/session-reference/tsconfig.json | 3 - .../context/tmux-context/README.i18n.yaml | 4 +- packages/context/tmux-context/README.md | 2 +- packages/context/tmux-context/README.zh.md | 2 +- packages/context/tmux-context/package.json | 7 - .../context/tmux-context/src/invariant.ts | 30 - packages/context/tmux-context/tsconfig.json | 3 - .../core/agent-default-model/README.i18n.yaml | 4 +- packages/core/agent-default-model/README.md | 2 +- .../core/agent-default-model/README.zh.md | 2 +- .../core/agent-default-model/package.json | 7 - .../core/agent-default-model/src/invariant.ts | 30 - .../core/agent-default-model/tsconfig.json | 3 - .../core/agent-default-model/tsdown.config.ts | 10 - .../agent-tool-presentation/README.i18n.yaml | 4 +- .../core/agent-tool-presentation/README.md | 2 +- .../core/agent-tool-presentation/README.zh.md | 2 +- .../core/agent-tool-presentation/package.json | 7 - .../agent-tool-presentation/src/invariant.ts | 32 - .../agent-tool-presentation/tsconfig.json | 3 - .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 2 +- .../credentials-local/package.json | 7 - .../credentials-local/src/invariant.ts | 31 - .../credentials-local/tsconfig.json | 3 - packages/e2b/e2b/README.i18n.yaml | 4 +- packages/e2b/e2b/README.md | 2 +- packages/e2b/e2b/README.zh.md | 2 +- packages/e2b/e2b/package.json | 7 - packages/e2b/e2b/src/invariant.ts | 30 - packages/e2b/e2b/tests/e2b.spec.ts | 11 +- packages/e2b/e2b/tsconfig.json | 3 - packages/e2b/fs-e2b/README.i18n.yaml | 4 +- packages/e2b/fs-e2b/README.md | 2 +- packages/e2b/fs-e2b/README.zh.md | 2 +- packages/e2b/fs-e2b/package.json | 7 - packages/e2b/fs-e2b/src/invariant.ts | 30 - packages/e2b/fs-e2b/tests/filesystem.spec.ts | 9 - packages/e2b/fs-e2b/tsconfig.json | 3 - packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 2 +- packages/e2b/subprocess-e2b/package.json | 7 - packages/e2b/subprocess-e2b/src/invariant.ts | 30 - .../subprocess-e2b/tests/subprocess.spec.ts | 9 - packages/e2b/subprocess-e2b/tsconfig.json | 4 +- .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 3 +- .../examples/agent-spine-demo/README.zh.md | 3 +- .../examples/agent-spine-demo/package.json | 5 - .../agent-spine-demo/src/invariant.ts | 30 - .../agent-team-profile/README.i18n.yaml | 4 +- .../experimental/agent-team-profile/README.md | 2 +- .../agent-team-profile/README.zh.md | 2 +- .../agent-team-profile/package.json | 7 - .../agent-team-profile/src/invariant.ts | 26 - .../agent-team-profile/tsconfig.json | 3 - .../agent-team-web-profile/README.i18n.yaml | 4 +- .../agent-team-web-profile/README.md | 2 +- .../agent-team-web-profile/README.zh.md | 2 +- .../agent-team-web-profile/package.json | 7 - .../agent-team-web-profile/src/invariant.ts | 23 - .../tests/profile.spec.ts | 16 - .../agent-team-web-profile/tsconfig.json | 3 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 2 + .../client-ui-agent-team/README.zh.md | 2 + .../client-ui-agent-team/package.json | 7 - .../client-ui-agent-team/src/invariant.ts | 22 - .../client-ui-agent-team/tsconfig.json | 3 +- .../client-ui-agent-team/tsdown.config.ts | 2 +- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 2 + packages/experimental/inspector/README.zh.md | 2 + packages/experimental/inspector/package.json | 9 +- .../experimental/inspector/src/invariant.ts | 22 - .../inspector/tests/cordis-tree.host.spec.ts | 17 + .../experimental/inspector/tsconfig.host.json | 4 - .../experimental/inspector/tsdown.config.ts | 2 +- .../tool-agent-team/README.i18n.yaml | 4 +- .../experimental/tool-agent-team/README.md | 2 +- .../experimental/tool-agent-team/README.zh.md | 2 +- .../experimental/tool-agent-team/package.json | 7 - .../tool-agent-team/src/invariant.ts | 18 - .../tool-agent-team/tsconfig.json | 3 +- .../webworker-packer/README.i18n.yaml | 4 +- .../experimental/webworker-packer/README.md | 2 + .../webworker-packer/README.zh.md | 2 + .../webworker-packer/package.json | 9 +- .../webworker-packer/src/invariant.ts | 31 - .../webworker-packer/tsconfig.json | 3 - .../webworker-packer/tsdown.config.ts | 2 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 + .../webworker-runtime/README.zh.md | 2 + .../webworker-runtime/package.json | 9 +- .../webworker-runtime/src/invariant.ts | 32 - .../webworker-runtime/tsconfig.json | 3 - .../webworker-runtime/tsdown.config.ts | 11 - .../cordis-client-runner/README.i18n.yaml | 4 +- .../extensions/cordis-client-runner/README.md | 2 + .../cordis-client-runner/README.zh.md | 2 + .../cordis-client-runner/package.json | 7 - .../cordis-client-runner/src/invariant.ts | 33 - .../tests/plugin.client.spec.ts | 22 +- .../cordis-client-runner/tsconfig.json | 3 - .../cordis-client-runner/tsdown.config.ts | 2 +- .../cordis-host-runner/README.i18n.yaml | 4 +- .../extensions/cordis-host-runner/README.md | 2 + .../cordis-host-runner/README.zh.md | 2 + .../cordis-host-runner/package.json | 7 - .../cordis-host-runner/src/invariant.ts | 32 - .../cordis-host-runner/tsconfig.json | 3 - .../extensions/tool-cordis/README.i18n.yaml | 4 +- packages/extensions/tool-cordis/README.md | 2 + packages/extensions/tool-cordis/README.zh.md | 2 + packages/extensions/tool-cordis/package.json | 7 - .../extensions/tool-cordis/src/invariant.ts | 30 - packages/extensions/tool-cordis/tsconfig.json | 3 - .../extensions/ui-cordis/README.i18n.yaml | 4 +- packages/extensions/ui-cordis/README.md | 2 + packages/extensions/ui-cordis/README.zh.md | 2 + packages/extensions/ui-cordis/package.json | 7 - .../extensions/ui-cordis/src/invariant.ts | 33 - packages/extensions/ui-cordis/tsconfig.json | 3 - .../extensions/ui-cordis/tsdown.config.ts | 2 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 2 +- .../feedback/command-feedback/README.zh.md | 2 +- .../feedback/command-feedback/package.json | 7 - .../command-feedback/src/invariant.ts | 30 - .../feedback/command-feedback/tsconfig.json | 3 - .../message-feedback/README.i18n.yaml | 4 +- packages/feedback/message-feedback/README.md | 2 +- .../feedback/message-feedback/README.zh.md | 2 +- .../feedback/message-feedback/package.json | 7 - .../message-feedback/src/invariant.ts | 27 - .../message-feedback/tests/invariant.spec.ts | 23 - .../feedback/message-feedback/tsconfig.json | 3 - packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 2 + packages/fs/fs-local/README.zh.md | 2 + packages/fs/fs-local/package.json | 7 - packages/fs/fs-local/src/invariant.ts | 30 - packages/fs/fs-local/tsconfig.json | 3 - .../fs/fs-observation-policy/README.i18n.yaml | 4 +- packages/fs/fs-observation-policy/README.md | 2 + .../fs/fs-observation-policy/README.zh.md | 2 + .../fs/fs-observation-policy/package.json | 7 - .../fs/fs-observation-policy/src/invariant.ts | 30 - .../fs/fs-observation-policy/tsconfig.json | 3 - packages/fs/fs-sandbox/README.i18n.yaml | 4 +- packages/fs/fs-sandbox/README.md | 2 + packages/fs/fs-sandbox/README.zh.md | 2 + packages/fs/fs-sandbox/package.json | 7 - packages/fs/fs-sandbox/src/invariant.ts | 27 - packages/fs/fs-sandbox/tsconfig.json | 3 - packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 2 + packages/fs/tool-fs-search/README.zh.md | 2 + packages/fs/tool-fs-search/package.json | 7 - packages/fs/tool-fs-search/src/invariant.ts | 30 - packages/fs/tool-fs-search/tsconfig.json | 3 - packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 + packages/fs/tool-fs/README.zh.md | 2 + packages/fs/tool-fs/package.json | 7 - packages/fs/tool-fs/src/invariant.ts | 30 - packages/fs/tool-fs/tsconfig.json | 3 - .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 2 + .../fs/tool-str-replace-editor/README.zh.md | 2 + .../fs/tool-str-replace-editor/package.json | 7 - .../tool-str-replace-editor/src/invariant.ts | 30 - .../fs/tool-str-replace-editor/tsconfig.json | 4 +- packages/goal/command-goal/README.i18n.yaml | 4 +- packages/goal/command-goal/README.md | 2 +- packages/goal/command-goal/README.zh.md | 2 +- packages/goal/command-goal/package.json | 7 - packages/goal/command-goal/src/invariant.ts | 30 - packages/goal/command-goal/tsconfig.json | 3 - packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/README.zh.md | 2 +- packages/goal/tool-goal/package.json | 7 - packages/goal/tool-goal/src/invariant.ts | 30 - packages/goal/tool-goal/tsconfig.json | 3 - .../repeat-tool-reminder/README.i18n.yaml | 4 +- packages/guard/repeat-tool-reminder/README.md | 2 +- .../guard/repeat-tool-reminder/README.zh.md | 2 +- .../guard/repeat-tool-reminder/package.json | 7 - .../repeat-tool-reminder/src/invariant.ts | 30 - .../guard/repeat-tool-reminder/tsconfig.json | 3 - .../guard/timeout-policy/README.i18n.yaml | 4 +- packages/guard/timeout-policy/README.md | 2 +- packages/guard/timeout-policy/README.zh.md | 2 +- packages/guard/timeout-policy/package.json | 7 - .../guard/timeout-policy/src/invariant.ts | 30 - packages/guard/timeout-policy/tsconfig.json | 3 - .../hooks/hooks-claude-code/README.i18n.yaml | 4 +- packages/hooks/hooks-claude-code/README.md | 2 +- packages/hooks/hooks-claude-code/README.zh.md | 2 +- packages/hooks/hooks-claude-code/package.json | 7 - .../hooks/hooks-claude-code/src/invariant.ts | 30 - .../hooks/hooks-claude-code/tsconfig.json | 3 - packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/package.json | 7 - packages/hooks/hooks-codex/src/invariant.ts | 30 - packages/hooks/hooks-codex/tsconfig.json | 3 - .../directory-picker-auto/README.i18n.yaml | 4 +- packages/host/directory-picker-auto/README.md | 2 + .../host/directory-picker-auto/README.zh.md | 2 + .../host/directory-picker-auto/package.json | 11 +- .../directory-picker-auto/src/invariant.ts | 25 - .../host/directory-picker-auto/tsconfig.json | 3 - .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/package.json | 7 - .../directory-picker-browse/src/invariant.ts | 25 - .../directory-picker-browse/tsconfig.json | 7 +- .../directory-picker-browse/tsdown.config.ts | 2 +- .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 2 + .../host/directory-picker-native/README.zh.md | 2 + .../host/directory-picker-native/package.json | 7 - .../directory-picker-native/src/invariant.ts | 25 - .../directory-picker-native/tsconfig.json | 3 - .../directory-picker-native/tsdown.config.ts | 2 +- .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 2 + packages/host/directory-picker/README.zh.md | 2 + packages/host/directory-picker/package.json | 7 - .../host/directory-picker/src/invariant.ts | 25 - packages/host/directory-picker/tsconfig.json | 3 - .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 + packages/host/frontend-static/README.zh.md | 2 + packages/host/frontend-static/package.json | 7 - .../host/frontend-static/src/invariant.ts | 34 - packages/host/frontend-static/tsconfig.json | 3 - .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/package.json | 7 - .../host/plugin-inventory/src/invariant.ts | 20 - .../plugin-inventory/tests/invariant.spec.ts | 16 - packages/host/plugin-inventory/tsconfig.json | 3 - packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 1 + packages/host/webserver/README.zh.md | 1 + packages/host/webserver/package.json | 9 +- packages/host/webserver/src/invariant.ts | 59 -- packages/host/webserver/tsconfig.json | 3 - .../anonymous-user-id/README.i18n.yaml | 4 +- packages/identity/anonymous-user-id/README.md | 9 +- .../identity/anonymous-user-id/README.zh.md | 9 +- .../identity/anonymous-user-id/package.json | 7 - .../anonymous-user-id/src/invariant.ts | 31 - .../anonymous-user-id/tests/invariant.spec.ts | 12 - .../identity/anonymous-user-id/tsconfig.json | 3 - .../tool-ask-user/README.i18n.yaml | 4 +- packages/interaction/tool-ask-user/README.md | 2 +- .../interaction/tool-ask-user/README.zh.md | 2 +- .../interaction/tool-ask-user/package.json | 7 - .../tool-ask-user/src/invariant.ts | 30 - .../interaction/tool-ask-user/tsconfig.json | 3 - .../user-questions/README.i18n.yaml | 4 +- packages/interaction/user-questions/README.md | 2 + .../interaction/user-questions/README.zh.md | 2 + .../interaction/user-questions/package.json | 7 - .../user-questions/src/invariant.ts | 30 - .../interaction/user-questions/tsconfig.json | 3 - packages/jobs/jobs-local/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.md | 2 +- packages/jobs/jobs-local/README.zh.md | 2 +- packages/jobs/jobs-local/package.json | 7 - packages/jobs/jobs-local/src/invariant.ts | 33 - packages/jobs/jobs-local/tsconfig.json | 3 - packages/jobs/tool-jobs/README.i18n.yaml | 4 +- packages/jobs/tool-jobs/README.md | 2 +- packages/jobs/tool-jobs/README.zh.md | 2 +- packages/jobs/tool-jobs/package.json | 7 - packages/jobs/tool-jobs/src/invariant.ts | 30 - packages/jobs/tool-jobs/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../llm/deepseek-llm-api-extensions/README.md | 2 + .../deepseek-llm-api-extensions/README.zh.md | 2 + .../deepseek-llm-api-extensions/package.json | 7 - .../src/invariant.ts | 27 - .../deepseek-llm-api-extensions/tsconfig.json | 3 - 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/package.json | 7 - packages/llm/llm-deepseek/src/invariant.ts | 30 - packages/llm/llm-deepseek/tsconfig.json | 3 - packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/package.json | 7 - packages/llm/llm-pi-ai/src/invariant.ts | 30 - packages/llm/llm-pi-ai/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../README.md | 2 + .../README.zh.md | 2 + .../package.json | 7 - .../src/invariant.ts | 27 - .../tsconfig.json | 3 - packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 2 + packages/llm/token-meter/README.zh.md | 2 + packages/llm/token-meter/package.json | 7 - packages/llm/token-meter/src/invariant.ts | 40 -- packages/llm/token-meter/tsconfig.json | 3 - packages/lsp/lsp-stdio/README.i18n.yaml | 4 +- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/package.json | 7 - packages/lsp/lsp-stdio/src/invariant.ts | 30 - packages/lsp/lsp-stdio/tsconfig.json | 3 - packages/lsp/lsp/README.i18n.yaml | 4 +- packages/lsp/lsp/README.md | 2 +- packages/lsp/lsp/README.zh.md | 2 +- packages/lsp/lsp/package.json | 7 - packages/lsp/lsp/src/invariant.ts | 30 - packages/lsp/lsp/tsconfig.json | 3 - packages/lsp/tool-lsp/README.i18n.yaml | 4 +- packages/lsp/tool-lsp/README.md | 2 +- packages/lsp/tool-lsp/README.zh.md | 2 +- packages/lsp/tool-lsp/package.json | 7 - packages/lsp/tool-lsp/src/invariant.ts | 30 - packages/lsp/tool-lsp/tsconfig.json | 3 - packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 2 +- packages/mcp/mcp-client/README.zh.md | 2 +- packages/mcp/mcp-client/package.json | 7 - packages/mcp/mcp-client/src/invariant.ts | 30 - packages/mcp/mcp-client/tsconfig.json | 3 - packages/preset/persona/README.i18n.yaml | 4 +- packages/preset/persona/README.md | 2 +- packages/preset/persona/README.zh.md | 2 +- packages/preset/persona/package.json | 7 - packages/preset/persona/src/invariant.ts | 31 - packages/preset/persona/tsconfig.json | 3 - .../invariants/README.i18n.yaml | 4 +- .../runtime-diagnostics/invariants/README.md | 8 +- .../invariants/README.zh.md | 8 +- .../invariants/package.json | 5 - .../invariants/src/invariant.ts | 30 - .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/package.json | 7 - .../sandbox/sandbox-local/src/invariant.ts | 30 - packages/sandbox/sandbox-local/tsconfig.json | 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 | 7 - .../sandbox-windows-acl/src/invariant.ts | 31 - .../sandbox/sandbox-windows-acl/tsconfig.json | 3 - .../sandbox-windows-acl/tsdown.config.ts | 2 +- packages/sandbox/sandbox/README.i18n.yaml | 4 +- packages/sandbox/sandbox/README.md | 2 +- packages/sandbox/sandbox/README.zh.md | 2 +- packages/sandbox/sandbox/package.json | 7 - packages/sandbox/sandbox/src/invariant.ts | 30 - packages/sandbox/sandbox/tsconfig.json | 3 - packages/sdk/client/README.i18n.yaml | 4 +- packages/sdk/client/README.md | 2 +- packages/sdk/client/README.zh.md | 2 +- packages/sdk/client/package.json | 7 - packages/sdk/client/src/invariant.ts | 31 - packages/sdk/client/tsconfig.json | 3 - packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 2 +- packages/sdk/protocol/README.zh.md | 2 +- packages/sdk/protocol/package.json | 7 - packages/sdk/protocol/src/invariant.ts | 31 - packages/sdk/protocol/tsconfig.json | 3 - packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/sdk/server/package.json | 7 - packages/sdk/server/src/invariant.ts | 30 - packages/sdk/server/tsconfig.json | 3 - .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 2 + .../session-log-export/README.zh.md | 2 + .../session-log-export/package.json | 5 +- .../session-log-export/src/invariant.ts | 25 - .../tests/invariant.host.spec.ts | 16 - .../session-log-export/tsconfig.host.json | 4 +- .../session-log-export/tsdown.config.ts | 2 +- .../session-query-sqlite/README.i18n.yaml | 4 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/README.zh.md | 2 +- .../session-query-sqlite/package.json | 7 - .../session-query-sqlite/src/invariant.ts | 30 - .../session-query-sqlite/tsconfig.json | 3 - .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 2 +- .../session-query/session-query/README.zh.md | 2 +- .../session-query/session-query/package.json | 7 - .../session-query/src/invariant.ts | 30 - .../session-query/session-query/tsconfig.json | 3 - .../tool-session-query/README.i18n.yaml | 4 +- .../tool-session-query/README.md | 2 + .../tool-session-query/README.zh.md | 2 + .../tool-session-query/package.json | 7 - .../tool-session-query/src/invariant.ts | 30 - .../tool-session-query/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../session-checkpoint-policy/README.md | 2 +- .../session-checkpoint-policy/README.zh.md | 2 +- .../session-checkpoint-policy/package.json | 7 - .../src/invariant.ts | 30 - .../session-checkpoint-policy/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/package.json | 7 - .../src/invariant.ts | 30 - .../session-persistence-jsonl/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/README.zh.md | 2 +- .../session-persistence-sqlite/package.json | 7 - .../src/invariant.ts | 30 - .../session-persistence-sqlite/tsconfig.json | 3 - .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session/session-persistence/package.json | 7 - .../session-persistence/src/invariant.ts | 30 - .../session/session-persistence/tsconfig.json | 3 - .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../session-projection-cache/package.json | 7 - .../session-projection-cache/src/invariant.ts | 35 - .../session-projection-cache/tsconfig.json | 3 - .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/package.json | 7 - .../session-projection/src/invariant.ts | 38 -- .../session/session-projection/tsconfig.json | 3 - .../session/session-stats/README.i18n.yaml | 4 +- packages/session/session-stats/README.md | 2 + packages/session/session-stats/README.zh.md | 2 + packages/session/session-stats/package.json | 7 - .../session/session-stats/src/invariant.ts | 35 - packages/session/session-stats/tsconfig.json | 3 - .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 2 + .../session-telemetry-otel/README.zh.md | 2 + .../session-telemetry-otel/package.json | 7 - .../session-telemetry-otel/src/invariant.ts | 31 - .../session-telemetry-otel/tsconfig.json | 3 - .../session-telemetry/README.i18n.yaml | 4 +- packages/session/session-telemetry/README.md | 2 + .../session/session-telemetry/README.zh.md | 2 + .../session/session-telemetry/package.json | 7 - .../session-telemetry/src/invariant.ts | 32 - .../session/session-telemetry/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../session-title-all-prompts-llm/README.md | 2 + .../README.zh.md | 2 + .../package.json | 8 +- .../src/invariant.ts | 30 - .../tsconfig.json | 1 - .../README.i18n.yaml | 4 +- .../session-title-first-prompt-llm/README.md | 2 + .../README.zh.md | 2 + .../package.json | 8 +- .../src/invariant.ts | 30 - .../tsconfig.json | 1 - .../session-title-llm/README.i18n.yaml | 4 +- packages/session/session-title-llm/README.md | 2 + .../session/session-title-llm/README.zh.md | 2 + .../session/session-title-llm/package.json | 7 - .../session-title-llm/src/invariant.ts | 30 - .../session/session-title-llm/tsconfig.json | 1 - .../settings/settings-file/README.i18n.yaml | 4 +- packages/settings/settings-file/README.md | 2 +- packages/settings/settings-file/README.zh.md | 2 +- packages/settings/settings-file/package.json | 7 - .../settings/settings-file/src/invariant.ts | 31 - packages/settings/settings-file/tsconfig.json | 3 - packages/shell/bash-local/README.i18n.yaml | 4 +- packages/shell/bash-local/README.md | 2 +- packages/shell/bash-local/README.zh.md | 2 +- packages/shell/bash-local/package.json | 7 - packages/shell/bash-local/src/invariant.ts | 30 - packages/shell/bash-local/tsconfig.json | 3 - packages/shell/bash-sandbox/README.i18n.yaml | 4 +- packages/shell/bash-sandbox/README.md | 2 +- packages/shell/bash-sandbox/README.zh.md | 2 +- packages/shell/bash-sandbox/package.json | 7 - packages/shell/bash-sandbox/src/invariant.ts | 30 - packages/shell/bash-sandbox/tsconfig.json | 3 - packages/shell/pwsh-local/README.i18n.yaml | 4 +- packages/shell/pwsh-local/README.md | 2 +- packages/shell/pwsh-local/README.zh.md | 2 +- packages/shell/pwsh-local/package.json | 7 - packages/shell/pwsh-local/src/invariant.ts | 30 - packages/shell/pwsh-local/tsconfig.json | 3 - packages/shell/pwsh-sandbox/README.i18n.yaml | 4 +- packages/shell/pwsh-sandbox/README.md | 2 +- packages/shell/pwsh-sandbox/README.zh.md | 2 +- packages/shell/pwsh-sandbox/package.json | 7 - packages/shell/pwsh-sandbox/src/invariant.ts | 30 - packages/shell/pwsh-sandbox/tsconfig.json | 3 - packages/shell/shell-env/README.i18n.yaml | 4 +- packages/shell/shell-env/README.md | 2 +- packages/shell/shell-env/README.zh.md | 2 +- packages/shell/shell-env/package.json | 7 - packages/shell/shell-env/src/invariant.ts | 30 - packages/shell/shell-env/tsconfig.json | 3 - packages/shell/shell/README.i18n.yaml | 4 +- packages/shell/shell/README.md | 2 +- packages/shell/shell/README.zh.md | 2 +- packages/shell/shell/package.json | 7 - packages/shell/shell/src/invariant.ts | 22 - packages/shell/shell/tsconfig.json | 3 - .../tool-bash-persistent/README.i18n.yaml | 4 +- packages/shell/tool-bash-persistent/README.md | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- .../shell/tool-bash-persistent/package.json | 7 - .../tool-bash-persistent/src/invariant.ts | 31 - .../shell/tool-bash-persistent/tsconfig.json | 2 +- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/package.json | 7 - packages/shell/tool-bash/src/invariant.ts | 30 - packages/shell/tool-bash/tsconfig.json | 3 - .../tool-pwsh-persistent/README.i18n.yaml | 4 +- packages/shell/tool-pwsh-persistent/README.md | 2 +- .../shell/tool-pwsh-persistent/README.zh.md | 2 +- .../shell/tool-pwsh-persistent/package.json | 7 - .../tool-pwsh-persistent/src/invariant.ts | 31 - .../shell/tool-pwsh-persistent/tsconfig.json | 1 - packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/package.json | 7 - packages/shell/tool-pwsh/src/invariant.ts | 30 - packages/shell/tool-pwsh/tsconfig.json | 3 - packages/skill/skill-badge/README.i18n.yaml | 4 +- packages/skill/skill-badge/README.md | 2 +- packages/skill/skill-badge/README.zh.md | 2 +- packages/skill/skill-badge/package.json | 7 - packages/skill/skill-badge/src/invariant.ts | 30 - packages/skill/skill-badge/tsconfig.json | 3 +- .../skill/skill-filesystem/README.i18n.yaml | 4 +- packages/skill/skill-filesystem/README.md | 2 +- packages/skill/skill-filesystem/README.zh.md | 2 +- packages/skill/skill-filesystem/package.json | 7 - .../skill/skill-filesystem/src/invariant.ts | 30 - packages/skill/skill-filesystem/tsconfig.json | 3 +- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 2 +- packages/skill/skill/README.zh.md | 2 +- packages/skill/skill/package.json | 7 - packages/skill/skill/src/invariant.ts | 30 - packages/skill/skill/tsconfig.json | 3 - packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/package.json | 7 - packages/skill/tool-skill/src/invariant.ts | 30 - packages/skill/tool-skill/tsconfig.json | 3 - packages/spill/spill-local/README.i18n.yaml | 4 +- packages/spill/spill-local/README.md | 2 +- packages/spill/spill-local/README.zh.md | 2 +- packages/spill/spill-local/package.json | 7 - packages/spill/spill-local/src/invariant.ts | 30 - packages/spill/spill-local/tsconfig.json | 3 - packages/spill/spill-policy/README.i18n.yaml | 4 +- packages/spill/spill-policy/README.md | 2 +- packages/spill/spill-policy/README.zh.md | 2 +- packages/spill/spill-policy/package.json | 7 - packages/spill/spill-policy/src/invariant.ts | 30 - packages/spill/spill-policy/tsconfig.json | 3 - packages/spill/spill/README.i18n.yaml | 4 +- packages/spill/spill/README.md | 2 +- packages/spill/spill/README.zh.md | 2 +- packages/spill/spill/package.json | 7 - packages/spill/spill/src/invariant.ts | 30 - packages/spill/spill/tsconfig.json | 3 - .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 2 +- packages/storage/storage-json/README.zh.md | 2 +- packages/storage/storage-json/package.json | 7 - .../storage/storage-json/src/invariant.ts | 32 - .../storage-json/tests/json-backend.spec.ts | 11 - packages/storage/storage-json/tsconfig.json | 3 - .../storage/storage-sqlite/README.i18n.yaml | 4 +- packages/storage/storage-sqlite/README.md | 2 +- packages/storage/storage-sqlite/README.zh.md | 2 +- packages/storage/storage-sqlite/package.json | 7 - .../storage/storage-sqlite/src/invariant.ts | 32 - .../storage-sqlite/tests/invariant.spec.ts | 12 - packages/storage/storage-sqlite/tsconfig.json | 3 - packages/storage/storage/README.i18n.yaml | 4 +- packages/storage/storage/README.md | 2 +- packages/storage/storage/README.zh.md | 2 +- packages/storage/storage/package.json | 7 - packages/storage/storage/src/invariant.ts | 32 - packages/storage/storage/tsconfig.json | 3 - .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 + packages/subagent/subagent-acp/README.zh.md | 2 + packages/subagent/subagent-acp/package.json | 7 - .../subagent/subagent-acp/src/invariant.ts | 30 - packages/subagent/subagent-acp/tsconfig.json | 3 - .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 + .../subagent-claude-code/README.zh.md | 2 + .../subagent-claude-code/package.json | 7 - .../subagent-claude-code/src/invariant.ts | 31 - .../tests/subagent-claude-code.spec.ts | 20 +- .../subagent-claude-code/tsconfig.json | 3 - .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 + packages/subagent/subagent-codex/README.zh.md | 2 + packages/subagent/subagent-codex/package.json | 7 - .../subagent/subagent-codex/src/invariant.ts | 30 - .../tests/subagent-codex.spec.ts | 20 +- .../subagent/subagent-codex/tsconfig.json | 3 - .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 2 + .../subagent/subagent-dsh-sdk/README.zh.md | 2 + .../subagent/subagent-dsh-sdk/package.json | 7 - .../subagent-dsh-sdk/src/invariant.ts | 31 - .../subagent/subagent-dsh-sdk/tsconfig.json | 3 - .../subagent-fork-in-process/README.i18n.yaml | 4 +- .../subagent-fork-in-process/README.md | 2 +- .../subagent-fork-in-process/README.zh.md | 2 +- .../subagent-fork-in-process/package.json | 6 - .../subagent-fork-in-process/src/invariant.ts | 30 - .../subagent-fork-in-process/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../subagent-in-process-driver/README.md | 2 +- .../subagent-in-process-driver/README.zh.md | 2 +- .../subagent-in-process-driver/package.json | 6 - .../src/invariant.ts | 30 - .../subagent-in-process-driver/tsconfig.json | 3 - .../README.i18n.yaml | 4 +- .../subagent-spawn-in-process/README.md | 2 +- .../subagent-spawn-in-process/README.zh.md | 2 +- .../subagent-spawn-in-process/package.json | 6 - .../src/invariant.ts | 30 - .../subagent-spawn-in-process/tsconfig.json | 3 - .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- .../tool-subagent-control/package.json | 7 - .../tool-subagent-control/src/invariant.ts | 30 - .../tool-subagent-control/tsconfig.json | 3 - .../tool-subagent-report/README.i18n.yaml | 4 +- .../subagent/tool-subagent-report/README.md | 2 +- .../tool-subagent-report/README.zh.md | 2 +- .../tool-subagent-report/package.json | 7 - .../tool-subagent-report/src/invariant.ts | 30 - .../tool-subagent-report/tsconfig.json | 6 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/package.json | 7 - .../subprocess-local/src/invariant.ts | 30 - .../subprocess/subprocess-local/tsconfig.json | 3 - .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/package.json | 7 - .../subprocess/subprocess/src/invariant.ts | 22 - packages/subprocess/subprocess/tsconfig.json | 3 - .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 + .../subprocess/win32-process/README.zh.md | 2 + .../subprocess/win32-process/package.json | 7 - .../subprocess/win32-process/src/invariant.ts | 17 - .../win32-process/tests/invariant.spec.ts | 16 - .../subprocess/win32-process/tsconfig.json | 3 - .../terminal/terminal-bash/README.i18n.yaml | 4 +- packages/terminal/terminal-bash/README.md | 2 + packages/terminal/terminal-bash/README.zh.md | 2 + packages/terminal/terminal-bash/package.json | 7 - .../terminal/terminal-bash/src/invariant.ts | 30 - packages/terminal/terminal-bash/tsconfig.json | 3 - packages/terminal/terminal/README.i18n.yaml | 4 +- packages/terminal/terminal/README.md | 2 +- packages/terminal/terminal/README.zh.md | 2 +- packages/terminal/terminal/package.json | 7 - packages/terminal/terminal/src/invariant.ts | 30 - packages/terminal/terminal/tsconfig.json | 3 - .../terminal/tool-terminal/README.i18n.yaml | 4 +- packages/terminal/tool-terminal/README.md | 2 + packages/terminal/tool-terminal/README.zh.md | 2 + packages/terminal/tool-terminal/package.json | 7 - .../terminal/tool-terminal/src/invariant.ts | 30 - packages/terminal/tool-terminal/tsconfig.json | 6 +- .../agent-loop-testkit/README.i18n.yaml | 4 +- .../test-support/agent-loop-testkit/README.md | 2 +- .../agent-loop-testkit/README.zh.md | 2 +- .../agent-loop-testkit/package.json | 7 - .../agent-loop-testkit/src/invariant.ts | 30 - .../agent-loop-testkit/tsconfig.json | 3 - .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../test-support/client-runtime/package.json | 7 - .../client-runtime/src/invariant.ts | 32 - .../tests/invariant.client.spec.ts | 12 - .../test-support/client-runtime/tsconfig.json | 3 - .../client-runtime/tsdown.config.ts | 2 +- .../llm-mock-server/README.i18n.yaml | 4 +- .../test-support/llm-mock-server/README.md | 2 +- .../test-support/llm-mock-server/README.zh.md | 2 +- .../test-support/llm-mock-server/package.json | 7 - .../llm-mock-server/src/invariant.ts | 30 - .../llm-mock-server/tests/invariant.spec.ts | 18 - .../llm-mock-server/tsconfig.json | 5 - .../llm-mock-server/tsdown.config.ts | 4 - .../test-support/llm-replay/README.i18n.yaml | 4 +- packages/test-support/llm-replay/README.md | 2 +- packages/test-support/llm-replay/README.zh.md | 2 +- packages/test-support/llm-replay/package.json | 7 - .../test-support/llm-replay/src/invariant.ts | 30 - .../test-support/llm-replay/tsconfig.json | 3 - .../loader-smoke/README.i18n.yaml | 4 +- packages/test-support/loader-smoke/README.md | 2 +- .../test-support/loader-smoke/README.zh.md | 2 +- .../test-support/loader-smoke/package.json | 7 - .../loader-smoke/src/invariant.ts | 30 - .../test-support/loader-smoke/tsconfig.json | 6 +- .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 +- .../session-snapshot/README.zh.md | 2 +- .../session-snapshot/package.json | 7 - .../session-snapshot/src/invariant.ts | 30 - .../session-snapshot/tsconfig.json | 3 - packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 2 + packages/typert/generator/README.zh.md | 2 + packages/typert/generator/package.json | 7 - packages/typert/generator/src/invariant.ts | 31 - packages/typert/generator/tsconfig.json | 3 - packages/typert/loader/README.i18n.yaml | 4 +- packages/typert/loader/README.md | 2 +- packages/typert/loader/README.zh.md | 2 +- packages/typert/loader/package.json | 7 - packages/typert/loader/src/invariant.ts | 30 - packages/typert/loader/tsconfig.json | 3 - packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 2 +- packages/typert/protocol/README.zh.md | 2 +- packages/typert/protocol/package.json | 7 - packages/typert/protocol/src/invariant.ts | 30 - packages/typert/protocol/tsconfig.json | 3 - packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 2 +- packages/typert/registry/README.zh.md | 2 +- packages/typert/registry/package.json | 7 - packages/typert/registry/src/invariant.ts | 31 - packages/typert/registry/tsconfig.json | 3 - packages/typert/registry/tsdown.config.ts | 2 +- packages/util/atomic-write/README.i18n.yaml | 4 +- packages/util/atomic-write/README.md | 2 +- packages/util/atomic-write/README.zh.md | 2 +- packages/util/atomic-write/package.json | 7 - packages/util/atomic-write/src/invariant.ts | 30 - .../util/atomic-write/tests/invariant.spec.ts | 18 - packages/util/atomic-write/tsconfig.json | 5 - packages/util/brand/README.i18n.yaml | 4 +- packages/util/brand/README.md | 2 +- packages/util/brand/README.zh.md | 2 +- packages/util/brand/package.json | 7 - packages/util/brand/src/invariant.ts | 30 - packages/util/brand/tsconfig.json | 5 - packages/util/crypto/README.i18n.yaml | 4 +- packages/util/crypto/README.md | 2 + packages/util/crypto/README.zh.md | 2 + packages/util/crypto/package.json | 7 - packages/util/crypto/src/invariant.ts | 30 - packages/util/crypto/tsconfig.json | 5 - packages/util/home-paths/README.i18n.yaml | 4 +- packages/util/home-paths/README.md | 2 +- packages/util/home-paths/README.zh.md | 2 +- packages/util/home-paths/package.json | 7 - packages/util/home-paths/src/invariant.ts | 30 - packages/util/home-paths/tsconfig.json | 5 - .../util/launch-environment/README.i18n.yaml | 4 +- packages/util/launch-environment/README.md | 2 +- packages/util/launch-environment/README.zh.md | 2 +- packages/util/launch-environment/package.json | 7 - .../util/launch-environment/src/invariant.ts | 30 - .../util/launch-environment/tsconfig.json | 2 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- packages/util/native-command/package.json | 7 - packages/util/native-command/src/invariant.ts | 31 - packages/util/native-command/tsconfig.json | 5 - .../util/output-retention/README.i18n.yaml | 4 +- packages/util/output-retention/README.md | 2 +- packages/util/output-retention/README.zh.md | 2 +- packages/util/output-retention/package.json | 7 - .../util/output-retention/src/invariant.ts | 30 - packages/util/output-retention/tsconfig.json | 5 - packages/util/timeout/README.i18n.yaml | 4 +- packages/util/timeout/README.md | 2 +- packages/util/timeout/README.zh.md | 2 +- packages/util/timeout/package.json | 7 - packages/util/timeout/src/invariant.ts | 30 - packages/util/timeout/tsconfig.json | 5 - packages/util/workspace-path/README.i18n.yaml | 4 +- packages/util/workspace-path/README.md | 2 + packages/util/workspace-path/README.zh.md | 2 + packages/util/workspace-path/package.json | 7 - packages/util/workspace-path/src/invariant.ts | 27 - packages/util/workspace-path/tsconfig.json | 5 - packages/web/tool-web/README.i18n.yaml | 4 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/README.zh.md | 2 +- packages/web/tool-web/package.json | 7 - packages/web/tool-web/src/invariant.ts | 30 - packages/web/tool-web/tsconfig.json | 3 - packages/web/web-fetch-http/README.i18n.yaml | 4 +- packages/web/web-fetch-http/README.md | 2 +- packages/web/web-fetch-http/README.zh.md | 2 +- packages/web/web-fetch-http/package.json | 7 - packages/web/web-fetch-http/src/invariant.ts | 30 - packages/web/web-fetch-http/tsconfig.json | 3 - .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 2 +- packages/web/web-search-deepseek/package.json | 7 - .../web/web-search-deepseek/src/invariant.ts | 31 - .../web/web-search-deepseek/tsconfig.json | 3 - packages/web/web-search-exa/README.i18n.yaml | 4 +- packages/web/web-search-exa/README.md | 2 +- packages/web/web-search-exa/README.zh.md | 2 +- packages/web/web-search-exa/package.json | 7 - packages/web/web-search-exa/src/invariant.ts | 30 - packages/web/web-search-exa/tsconfig.json | 3 - .../web-search-perplexity/README.i18n.yaml | 4 +- packages/web/web-search-perplexity/README.md | 2 +- .../web/web-search-perplexity/README.zh.md | 2 +- .../web/web-search-perplexity/package.json | 7 - .../web-search-perplexity/src/invariant.ts | 30 - .../web/web-search-perplexity/tsconfig.json | 3 - packages/web/web/README.i18n.yaml | 4 +- packages/web/web/README.md | 2 +- packages/web/web/README.zh.md | 2 +- packages/web/web/package.json | 7 - packages/web/web/src/invariant.ts | 30 - packages/web/web/tsconfig.json | 3 - .../webhook/webhook-github/README.i18n.yaml | 4 +- packages/webhook/webhook-github/README.md | 2 + packages/webhook/webhook-github/README.zh.md | 2 + packages/webhook/webhook-github/package.json | 7 - .../webhook/webhook-github/src/invariant.ts | 25 - .../webhook-github/tests/invariant.spec.ts | 13 - packages/webhook/webhook-github/tsconfig.json | 3 - packages/workflow/tool-ralph/README.i18n.yaml | 4 +- packages/workflow/tool-ralph/README.md | 2 +- packages/workflow/tool-ralph/README.zh.md | 2 +- packages/workflow/tool-ralph/package.json | 7 - packages/workflow/tool-ralph/src/invariant.ts | 30 - packages/workflow/tool-ralph/tsconfig.json | 3 - .../workflow-worker-thread/README.i18n.yaml | 4 +- .../workflow/workflow-worker-thread/README.md | 2 +- .../workflow-worker-thread/README.zh.md | 2 +- .../workflow-worker-thread/package.json | 6 - .../workflow-worker-thread/src/invariant.ts | 30 - .../workflow-worker-thread/tsconfig.json | 3 - .../workflow-worker-thread/tsdown.config.ts | 2 +- pnpm-lock.yaml | 600 ----------------- scripts/check-workspace-constraints.spec.ts | 1 - scripts/check-workspace-constraints.ts | 6 +- scripts/package-invariants.spec.ts | 82 ++- scripts/package-invariants.ts | 103 ++- scripts/test-invariants.spec.ts | 5 +- scripts/test-invariants.ts | 14 +- scripts/verify-built-package-invariants.mjs | 6 +- .../verify-built-package-invariants.spec.ts | 25 +- tsconfig.base.json | 136 ---- vitest.config.ts | 1 - 1407 files changed, 1986 insertions(+), 11398 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md create mode 100644 .agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md delete mode 100644 packages/acp/acp/src/invariant.ts delete mode 100644 packages/api/gateway/src/invariant.ts delete mode 100644 packages/api/remotes/src/invariant.ts delete mode 100644 packages/api/session-controller/src/invariant.ts delete mode 100644 packages/api/settings-controller/src/invariant.ts delete mode 100644 packages/api/settings-controller/tests/invariant.spec.ts delete mode 100644 packages/api/workspace-controller/src/invariant.ts delete mode 100644 packages/attachment/attachment-local/src/invariant.ts delete mode 100644 packages/attachment/attachment/src/invariant.ts delete mode 100644 packages/boot/app-boot/src/invariant.ts delete mode 100644 packages/boot/cmdline/src/invariant.ts delete mode 100644 packages/bundle/acp-app/src/invariant.ts delete mode 100644 packages/bundle/base/src/invariant.ts delete mode 100644 packages/bundle/headless/src/invariant.ts delete mode 100644 packages/bundle/sdk-app/src/invariant.ts delete mode 100644 packages/bundle/sdk-minimal/src/invariant.ts delete mode 100644 packages/bundle/web-app/src/invariant.ts delete mode 100644 packages/client/connection/src/invariant.ts delete mode 100644 packages/client/locale/src/invariant.ts delete mode 100644 packages/client/locale/tests/invariant.client.spec.ts delete mode 100644 packages/client/store/src/invariant.ts delete mode 100644 packages/client/store/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-agent-preset/src/invariant.ts delete mode 100644 packages/client/ui-agent-preset/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-approval/src/invariant.ts delete mode 100644 packages/client/ui-attachment/src/invariant.ts delete mode 100644 packages/client/ui-attachment/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-brand-official/src/invariant.ts delete mode 100644 packages/client/ui-brand-official/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-chat/src/invariant.ts delete mode 100644 packages/client/ui-commands/src/invariant.ts delete mode 100644 packages/client/ui-conversation/src/invariant.ts delete mode 100644 packages/client/ui-deliverables/src/invariant.ts delete mode 100644 packages/client/ui-directory-picker-browse/src/invariant.ts delete mode 100644 packages/client/ui-directory-picker-native/src/invariant.ts delete mode 100644 packages/client/ui-goal/src/invariant.ts delete mode 100644 packages/client/ui-input-trigger/src/invariant.ts delete mode 100644 packages/client/ui-jobs/src/invariant.ts delete mode 100644 packages/client/ui-layout/src/invariant.ts delete mode 100644 packages/client/ui-message-feedback/src/invariant.ts delete mode 100644 packages/client/ui-model-selection/src/invariant.ts delete mode 100644 packages/client/ui-permission-presets/src/invariant.ts delete mode 100644 packages/client/ui-plan/src/invariant.ts delete mode 100644 packages/client/ui-primitives/src/invariant.ts delete mode 100644 packages/client/ui-primitives/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-reference/src/invariant.ts delete mode 100644 packages/client/ui-session/src/invariant.ts delete mode 100644 packages/client/ui-settings-general/src/invariant.ts delete mode 100644 packages/client/ui-settings-general/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-settings-models/src/invariant.ts delete mode 100644 packages/client/ui-settings-models/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-settings-plugin-inventory/src/invariant.ts delete mode 100644 packages/client/ui-settings-plugin-inventory/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-settings-plugins/src/invariant.ts delete mode 100644 packages/client/ui-settings-plugins/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-settings/src/invariant.ts delete mode 100644 packages/client/ui-settings/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-sidebar/src/invariant.ts delete mode 100644 packages/client/ui-sidebar/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-skill/src/invariant.ts delete mode 100644 packages/client/ui-slots/src/invariant.ts delete mode 100644 packages/client/ui-slots/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-subagent/src/invariant.ts delete mode 100644 packages/client/ui-theme/src/invariant.ts delete mode 100644 packages/client/ui-theme/tests/invariant.client.spec.ts delete mode 100644 packages/client/ui-tool/src/invariant.ts delete mode 100644 packages/client/ui-trajectory/src/invariant.ts delete mode 100644 packages/client/ui-user-questions/src/invariant.ts delete mode 100644 packages/client/ui-workflow-run/src/invariant.ts delete mode 100644 packages/client/ui-workspace/src/invariant.ts delete mode 100644 packages/client/ui-workspace/tests/invariant.client.spec.ts delete mode 100644 packages/client/web/src/invariant.ts delete mode 100644 packages/code-runtime/code-runtime-python/src/invariant.ts delete mode 100644 packages/code-runtime/code-runtime-worker-thread/src/invariant.ts delete mode 100644 packages/code-runtime/code-runtime/src/invariant.ts delete mode 100644 packages/compaction/command-compact/src/invariant.ts delete mode 100644 packages/compaction/command-compact/tests/invariant.spec.ts delete mode 100644 packages/compaction/compaction-basic/src/invariant.ts delete mode 100644 packages/compaction/compaction-tool-result-pruner/src/invariant.ts delete mode 100644 packages/context/agent-instructions/src/invariant.ts delete mode 100644 packages/context/file-reference-local/src/invariant.ts delete mode 100644 packages/context/file-reference-local/tests/invariant.spec.ts delete mode 100644 packages/context/file-reference/src/invariant.ts delete mode 100644 packages/context/file-reference/tests/invariant.spec.ts delete mode 100644 packages/context/session-reference/src/invariant.ts delete mode 100644 packages/context/tmux-context/src/invariant.ts delete mode 100644 packages/core/agent-default-model/src/invariant.ts delete mode 100644 packages/core/agent-tool-presentation/src/invariant.ts delete mode 100644 packages/credentials/credentials-local/src/invariant.ts delete mode 100644 packages/e2b/e2b/src/invariant.ts delete mode 100644 packages/e2b/fs-e2b/src/invariant.ts delete mode 100644 packages/e2b/subprocess-e2b/src/invariant.ts delete mode 100644 packages/examples/agent-spine-demo/src/invariant.ts delete mode 100644 packages/experimental/agent-team-profile/src/invariant.ts delete mode 100644 packages/experimental/agent-team-web-profile/src/invariant.ts delete mode 100644 packages/experimental/client-ui-agent-team/src/invariant.ts delete mode 100644 packages/experimental/inspector/src/invariant.ts delete mode 100644 packages/experimental/tool-agent-team/src/invariant.ts delete mode 100644 packages/experimental/webworker-packer/src/invariant.ts delete mode 100644 packages/experimental/webworker-runtime/src/invariant.ts delete mode 100644 packages/extensions/cordis-client-runner/src/invariant.ts delete mode 100644 packages/extensions/cordis-host-runner/src/invariant.ts delete mode 100644 packages/extensions/tool-cordis/src/invariant.ts delete mode 100644 packages/extensions/ui-cordis/src/invariant.ts delete mode 100644 packages/feedback/command-feedback/src/invariant.ts delete mode 100644 packages/feedback/message-feedback/src/invariant.ts delete mode 100644 packages/feedback/message-feedback/tests/invariant.spec.ts delete mode 100644 packages/fs/fs-local/src/invariant.ts delete mode 100644 packages/fs/fs-observation-policy/src/invariant.ts delete mode 100644 packages/fs/fs-sandbox/src/invariant.ts delete mode 100644 packages/fs/tool-fs-search/src/invariant.ts delete mode 100644 packages/fs/tool-fs/src/invariant.ts delete mode 100644 packages/fs/tool-str-replace-editor/src/invariant.ts delete mode 100644 packages/goal/command-goal/src/invariant.ts delete mode 100644 packages/goal/tool-goal/src/invariant.ts delete mode 100644 packages/guard/repeat-tool-reminder/src/invariant.ts delete mode 100644 packages/guard/timeout-policy/src/invariant.ts delete mode 100644 packages/hooks/hooks-claude-code/src/invariant.ts delete mode 100644 packages/hooks/hooks-codex/src/invariant.ts delete mode 100644 packages/host/directory-picker-auto/src/invariant.ts delete mode 100644 packages/host/directory-picker-browse/src/invariant.ts delete mode 100644 packages/host/directory-picker-native/src/invariant.ts delete mode 100644 packages/host/directory-picker/src/invariant.ts delete mode 100644 packages/host/frontend-static/src/invariant.ts delete mode 100644 packages/host/plugin-inventory/src/invariant.ts delete mode 100644 packages/host/plugin-inventory/tests/invariant.spec.ts delete mode 100644 packages/host/webserver/src/invariant.ts delete mode 100644 packages/identity/anonymous-user-id/src/invariant.ts delete mode 100644 packages/identity/anonymous-user-id/tests/invariant.spec.ts delete mode 100644 packages/interaction/tool-ask-user/src/invariant.ts delete mode 100644 packages/interaction/user-questions/src/invariant.ts delete mode 100644 packages/jobs/jobs-local/src/invariant.ts delete mode 100644 packages/jobs/tool-jobs/src/invariant.ts delete mode 100644 packages/llm/deepseek-llm-api-extensions/src/invariant.ts delete mode 100644 packages/llm/llm-deepseek/src/invariant.ts delete mode 100644 packages/llm/llm-pi-ai/src/invariant.ts delete mode 100644 packages/llm/plugin-package-inventory-deepseek/src/invariant.ts delete mode 100644 packages/llm/token-meter/src/invariant.ts delete mode 100644 packages/lsp/lsp-stdio/src/invariant.ts delete mode 100644 packages/lsp/lsp/src/invariant.ts delete mode 100644 packages/lsp/tool-lsp/src/invariant.ts delete mode 100644 packages/mcp/mcp-client/src/invariant.ts delete mode 100644 packages/preset/persona/src/invariant.ts delete mode 100644 packages/runtime-diagnostics/invariants/src/invariant.ts delete mode 100644 packages/sandbox/sandbox-local/src/invariant.ts delete mode 100644 packages/sandbox/sandbox-windows-acl/src/invariant.ts delete mode 100644 packages/sandbox/sandbox/src/invariant.ts delete mode 100644 packages/sdk/client/src/invariant.ts delete mode 100644 packages/sdk/protocol/src/invariant.ts delete mode 100644 packages/sdk/server/src/invariant.ts delete mode 100644 packages/session-query/session-log-export/src/invariant.ts delete mode 100644 packages/session-query/session-log-export/tests/invariant.host.spec.ts delete mode 100644 packages/session-query/session-query-sqlite/src/invariant.ts delete mode 100644 packages/session-query/session-query/src/invariant.ts delete mode 100644 packages/session-query/tool-session-query/src/invariant.ts delete mode 100644 packages/session/session-checkpoint-policy/src/invariant.ts delete mode 100644 packages/session/session-persistence-jsonl/src/invariant.ts delete mode 100644 packages/session/session-persistence-sqlite/src/invariant.ts delete mode 100644 packages/session/session-persistence/src/invariant.ts delete mode 100644 packages/session/session-projection-cache/src/invariant.ts delete mode 100644 packages/session/session-projection/src/invariant.ts delete mode 100644 packages/session/session-stats/src/invariant.ts delete mode 100644 packages/session/session-telemetry-otel/src/invariant.ts delete mode 100644 packages/session/session-telemetry/src/invariant.ts delete mode 100644 packages/session/session-title-all-prompts-llm/src/invariant.ts delete mode 100644 packages/session/session-title-first-prompt-llm/src/invariant.ts delete mode 100644 packages/session/session-title-llm/src/invariant.ts delete mode 100644 packages/settings/settings-file/src/invariant.ts delete mode 100644 packages/shell/bash-local/src/invariant.ts delete mode 100644 packages/shell/bash-sandbox/src/invariant.ts delete mode 100644 packages/shell/pwsh-local/src/invariant.ts delete mode 100644 packages/shell/pwsh-sandbox/src/invariant.ts delete mode 100644 packages/shell/shell-env/src/invariant.ts delete mode 100644 packages/shell/shell/src/invariant.ts delete mode 100644 packages/shell/tool-bash-persistent/src/invariant.ts delete mode 100644 packages/shell/tool-bash/src/invariant.ts delete mode 100644 packages/shell/tool-pwsh-persistent/src/invariant.ts delete mode 100644 packages/shell/tool-pwsh/src/invariant.ts delete mode 100644 packages/skill/skill-badge/src/invariant.ts delete mode 100644 packages/skill/skill-filesystem/src/invariant.ts delete mode 100644 packages/skill/skill/src/invariant.ts delete mode 100644 packages/skill/tool-skill/src/invariant.ts delete mode 100644 packages/spill/spill-local/src/invariant.ts delete mode 100644 packages/spill/spill-policy/src/invariant.ts delete mode 100644 packages/spill/spill/src/invariant.ts delete mode 100644 packages/storage/storage-json/src/invariant.ts delete mode 100644 packages/storage/storage-sqlite/src/invariant.ts delete mode 100644 packages/storage/storage-sqlite/tests/invariant.spec.ts delete mode 100644 packages/storage/storage/src/invariant.ts delete mode 100644 packages/subagent/subagent-acp/src/invariant.ts delete mode 100644 packages/subagent/subagent-claude-code/src/invariant.ts delete mode 100644 packages/subagent/subagent-codex/src/invariant.ts delete mode 100644 packages/subagent/subagent-dsh-sdk/src/invariant.ts delete mode 100644 packages/subagent/subagent-fork-in-process/src/invariant.ts delete mode 100644 packages/subagent/subagent-in-process-driver/src/invariant.ts delete mode 100644 packages/subagent/subagent-spawn-in-process/src/invariant.ts delete mode 100644 packages/subagent/tool-subagent-control/src/invariant.ts delete mode 100644 packages/subagent/tool-subagent-report/src/invariant.ts delete mode 100644 packages/subprocess/subprocess-local/src/invariant.ts delete mode 100644 packages/subprocess/subprocess/src/invariant.ts delete mode 100644 packages/subprocess/win32-process/src/invariant.ts delete mode 100644 packages/subprocess/win32-process/tests/invariant.spec.ts delete mode 100644 packages/terminal/terminal-bash/src/invariant.ts delete mode 100644 packages/terminal/terminal/src/invariant.ts delete mode 100644 packages/terminal/tool-terminal/src/invariant.ts delete mode 100644 packages/test-support/agent-loop-testkit/src/invariant.ts delete mode 100644 packages/test-support/client-runtime/src/invariant.ts delete mode 100644 packages/test-support/client-runtime/tests/invariant.client.spec.ts delete mode 100644 packages/test-support/llm-mock-server/src/invariant.ts delete mode 100644 packages/test-support/llm-mock-server/tests/invariant.spec.ts delete mode 100644 packages/test-support/llm-replay/src/invariant.ts delete mode 100644 packages/test-support/loader-smoke/src/invariant.ts delete mode 100644 packages/test-support/session-snapshot/src/invariant.ts delete mode 100644 packages/typert/generator/src/invariant.ts delete mode 100644 packages/typert/loader/src/invariant.ts delete mode 100644 packages/typert/protocol/src/invariant.ts delete mode 100644 packages/typert/registry/src/invariant.ts delete mode 100644 packages/util/atomic-write/src/invariant.ts delete mode 100644 packages/util/atomic-write/tests/invariant.spec.ts delete mode 100644 packages/util/brand/src/invariant.ts delete mode 100644 packages/util/crypto/src/invariant.ts delete mode 100644 packages/util/home-paths/src/invariant.ts delete mode 100644 packages/util/launch-environment/src/invariant.ts delete mode 100644 packages/util/native-command/src/invariant.ts delete mode 100644 packages/util/output-retention/src/invariant.ts delete mode 100644 packages/util/timeout/src/invariant.ts delete mode 100644 packages/util/workspace-path/src/invariant.ts delete mode 100644 packages/web/tool-web/src/invariant.ts delete mode 100644 packages/web/web-fetch-http/src/invariant.ts delete mode 100644 packages/web/web-search-deepseek/src/invariant.ts delete mode 100644 packages/web/web-search-exa/src/invariant.ts delete mode 100644 packages/web/web-search-perplexity/src/invariant.ts delete mode 100644 packages/web/web/src/invariant.ts delete mode 100644 packages/webhook/webhook-github/src/invariant.ts delete mode 100644 packages/webhook/webhook-github/tests/invariant.spec.ts delete mode 100644 packages/workflow/tool-ralph/src/invariant.ts delete mode 100644 packages/workflow/workflow-worker-thread/src/invariant.ts diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 44272f7ca1..6b72ef7c79 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.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-06-11-dev-invariants-over-deep-readonly.md -2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff +2026-06-11-dev-invariants-over-deep-readonly.md: e4f8b434b87524b1c0816962328fa61f00138b2a +2026-06-11-dev-invariants-over-deep-readonly.zh.md: eaeab626089a9d7abb088bf3a580f05f8c6b13ff diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 66980f1ee0..e4f8b434b8 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -32,7 +32,7 @@ This guarantee belongs in `Session`, not in an optional listener, because every ### Package-owned invariant companions check relationships -`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Every package publishes a `./invariant` ownership companion; `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` currently add the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). +`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. A package publishes a `./invariant` ownership companion only for an independently observable runtime relationship; packages without one omit the companion and record the reason in their README. `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` provide the initial rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md); [omission decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md)). When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage. diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 576e53e0b2..eaeab62608 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -32,7 +32,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 包拥有的不变式配套插件检查关系 -`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md))。 +`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。只有拥有可独立观察的运行时关系时,包才发布 `./invariant` 所有权配套插件;没有该关系的包会省略 companion 并在 README 中记录原因。`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 提供首批需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md)与[省略决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md))。 当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 6ec0a2a7e5..5ff9e84550 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.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-package-invariant-runtime-contracts.md -2026-07-19-package-invariant-runtime-contracts.md: b5799a37a61244193b46db6ea4ae15f306d144b2 -2026-07-19-package-invariant-runtime-contracts.zh.md: e6035bbabba7188017746c57c5b6a48761710658 +2026-07-19-package-invariant-runtime-contracts.md: 6b072b6f7149abc42b87859cd593b46eb04bd0e8 +2026-07-19-package-invariant-runtime-contracts.zh.md: e1c661174a545ca2d7abf6406b7ac2293cc7c6d5 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index b5799a37a6..6b072b6f71 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -14,20 +14,20 @@ Some packages genuinely own no continuously observable relation. Pure utilities, ## Decision -### Registration is exhaustive; assertions must be meaningful +### Published assertions must be meaningful -Every workspace package publishes a separately built `./invariant` companion and registers its exact npm package name. A companion does one of two things: +A workspace package publishes a separately built `./invariant` companion only when it owns an independently observable runtime relationship. A published companion: -- installs a package-owned check over an event stream or relevant mutable data structure and reports violations through its bound `fail(message)` reporter; or -- uses an empty installer whose declaration has an owner-specific `No runtime invariant:` comment explaining why the package has no plausible runtime relation to observe. +- installs a package-owned check over an event stream or relevant mutable data structure and reports violations through its bound `fail(message)` reporter; and +- registers the package's exact npm name while keeping diagnostics outside the root entrypoint. -The empty form is an explicit architectural conclusion, not a generated placeholder. A future package change that introduces mutable state or an event protocol must replace the explanation with the corresponding check. +When no plausible relationship exists, the package omits the companion and publication wiring and records its package-specific reason in the README. A future change that introduces an independently observable relationship must replace the explanation with the corresponding check. The omission mechanics and current audit are owned by the [omit-unneeded-companions decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md). The central `dsh-invariants` service owns only configuration, registration uniqueness, child-fiber lifecycle, rollback, disposal, and package-attributed failure. It exposes no generic plugin-shape, service-shape, or startup-assertion helpers and imports no product package. ### Implemented checks -The current 103-package workspace has 21 executable companions and 82 justified empty companions. +Published companions are enumerated mechanically by `verify-package-invariants`; the current audit count is recorded in the [omit-unneeded-companions decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md). | Owner | Runtime relationship | |---|---| @@ -57,13 +57,13 @@ Session-backed companions validate existing durable events when they load, using ### Repository gate and tests -`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. +`verify-package-invariants` discovers every workspace package. It accepts clean omission, rejects stale or partial companion wiring, and enforces exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries for published companions. Its AST rule rejects generated markers, default exports, and empty installers. Every installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. -Vitest mounts `InvariantRegistry` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. +Vitest mounts `InvariantRegistry` with `{ enabled: true }` for every package test topology and loads the owning companion when one is published. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every published companion's valid and invalid observations, and the exhaustive topology runs every source companion through real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered -- **Keep generated empty companions.** Rejected because an unexplained placeholder can survive after a package gains a meaningful runtime relation. +- **Keep explained empty companions.** Rejected because source, publication, dependency, and test wiring are disproportionate machinery for a negative conclusion that belongs in the package README. - **Require an assertion from every package.** Rejected because method-presence, plugin-shape, and fixed-example assertions duplicate stronger type, load, and unit-test contracts without checking runtime consistency. - **Keep generic shape helpers in the service.** Rejected because they blur compile-time API validation with runtime invariants and encourage centrally defined product assumptions. - **Move the product checks into the service.** Rejected because product vocabulary, dependencies, tests, and change ownership belong with the package that emits the data. @@ -71,8 +71,8 @@ Vitest mounts `InvariantRegistry` with `{ enabled: true }` for every package tes ## Consequences -- Every package has visible ownership and publication wiring, but only packages with a plausible runtime relation add listeners or trace state. -- Empty companions remain reviewable decisions with package-specific explanations and fail the gate if the explanation is removed. +- Packages with a plausible runtime relation have visible ownership and publication wiring; packages without one record the omission reason in their README. +- Empty companions fail the gate, and partial omission wiring fails before build or release. - Type declarations, Cordis loadability, plugin metadata, service method APIs, and pure algebra remain covered by their owning compile, load, unit, or integration gates. - Runtime failures identify the owning npm package and point to an inconsistent observation rather than restating a required API shape. - The original selection, blocklist precedence, duplicate ownership, rollback, disposal, and HMR service contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index e6035bbabb..e1c661174a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -14,20 +14,20 @@ Status: implemented ## 决策 -### 注册必须全覆盖;断言必须有意义 +### 已发布的断言必须有意义 -每个 workspace 包都发布单独构建的 `./invariant` companion,并用完整 npm 包名注册。companion 只能采用以下两种形式之一: +只有拥有可独立观察的运行时关系时,workspace 包才发布单独构建的 `./invariant` companion。已发布 companion 必须: -- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;或 -- 使用空安装器,并在其声明前写一条该包专属的 `No runtime invariant:` 注释,说明为什么该包没有合理的运行时关系可供观测。 +- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;并且 +- 用该包的准确 npm 包名注册,同时保持诊断逻辑不进入根入口。 -空形式是明确的架构结论,不是生成占位符。如果后续包变更引入可变状态或事件协议,就必须用相应检查替换该说明。 +没有合理关系时,包会省略 companion 与发布接线,并在 README 中记录该包的具体原因。如果后续变更引入可独立观察的关系,就必须用相应检查替换该说明。省略机制与当前审计由[省略不必要 companion 的决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)负责。 中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、dispose(资源释放)和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。 ### 已实施的检查 -当前 103 个包的 workspace 包含 21 个可执行 companion 和 82 个有理由的空 companion。 +已发布 companion 由 `verify-package-invariants` 机械枚举;当前审计数量记录在[省略不必要 companion 的决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)中。 | 所有者 | 运行时关系 | |---|---| @@ -57,13 +57,13 @@ Status: implemented ### 仓库门禁与测试 -`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 +`verify-package-invariants` 发现每个 workspace 包。它接受完整省略,拒绝陈旧或不完整的 companion 接线,并对已发布 companion 强制完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和空 installer。每个 installer 都必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 -Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantRegistry`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantRegistry`,并在所有者发布 companion 时加载它。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个已发布 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 -- **保留生成的空 companion。** 拒绝,因为包获得有意义的运行时关系后,没有解释的占位符仍可能继续存在。 +- **保留带说明的空 companion。** 拒绝,因为只为表达 README 可以直接记录的否定结论而保留源码、发布、依赖与测试接线,成本过高。 - **要求每个包都执行断言。** 拒绝,因为方法存在性、插件形状和固定示例断言会重复更强的类型、加载和单元测试约定,却没有检查运行时一致性。 - **在服务中保留通用形状 helper。** 拒绝,因为这会混淆编译期 API 验证和运行时不变量,并鼓励在中央定义产品假设。 - **把产品检查移入服务。** 拒绝,因为产品词汇、依赖、测试和变更所有权应归属于产生这些数据的包。 @@ -71,8 +71,8 @@ Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantRegis ## 后果 -- 每个包都有可见的所有权与发布 wiring,但只有具备合理运行时关系的包才会增加 listener 或 trace 状态。 -- 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。 +- 拥有合理运行时关系的包具有可见的所有权与发布 wiring;没有该关系的包会在 README 中记录省略原因。 +- 空 companion 会让门禁失败,不完整的省略接线也会在构建或发布前失败。 - 类型声明、Cordis 可加载性、插件 metadata、服务方法 API 和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。 - 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。 - 原有 selection、blocklist 优先级、重复所有权、回滚、dispose 和 HMR(热模块替换)服务约定保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 7c864f90ad..e46536b314 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.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-package-owned-invariant-service.md -2026-07-19-package-owned-invariant-service.md: f1918ec1d31f9d91538b6b92070d98217567c5c5 -2026-07-19-package-owned-invariant-service.zh.md: 81e8e0361e2ebe4d34dae1c064f928fb57c49b8a +2026-07-19-package-owned-invariant-service.md: b59d733ae6c3db6b534e751b73ccad1c62fd360f +2026-07-19-package-owned-invariant-service.zh.md: 050377a8d31874c40c6959f7c0b92a88b9f5e554 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index f1918ec1d3..b59d733ae6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -10,7 +10,7 @@ Runtime invariant checks span session traces, agent state, scoped dispatch, and Deployments that opt into diagnostics need more than presence or absence of one plugin. Such a composition carries the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. -Package ownership must also be exhaustive. Without a mechanical repository rule, a new package can omit the companion, dependency, or publication wiring and remain invisible to diagnostics until a maintainer notices the gap. +Published ownership must be mechanically complete. Without a repository rule, a package can expose a partial companion, dependency, or publication map and remain broken until a maintainer notices the gap; packages that publish none must keep their reason reviewable in the README. ## Decision @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A companion checks a meaningful event or mutable-data relationship when its owner has one; otherwise it carries an owner-specific explanation for its empty installer. Generated ownership placeholders and synthetic API-shape assertions are forbidden by the follow-up [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +A workspace package publishes a `./invariant` companion plugin only when it owns an independently observable event or mutable-data relationship. The companion registers its exact full npm name. Packages without such a relationship omit the companion and publication wiring and record the reason in their README; generated placeholders, empty installers, and synthetic API-shape assertions are forbidden by the [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md) and [omission decision](../simplification/2026-08-28-omit-unneeded-invariant-companions.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -64,9 +64,9 @@ The former functional-plugin entry point and one-argument `InvariantError` const | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for seventeen more owners with real event or mutable-data relationships and records justified empty companions for the rest. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners supplied the initial stateful checks. Later owners add companions for real event or mutable-data relationships, while packages without one omit the companion and document why. Every published companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape. -`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. +`verify-package-invariants` discovers every workspace package, accepts clean omission, and rejects partial companion wiring, generated markers, empty installers, installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit a published companion entry. ### Scoped-event semantic map @@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. -Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion. One exhaustive topology mounts all package companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. +Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion when one exists. One exhaustive topology mounts all published companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every published companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. ## Alternatives considered @@ -96,10 +96,10 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the publication and dependency cost of a companion; only owners with a meaningful runtime relationship add listener or trace-state cost. +- Only owners with a meaningful runtime relationship pay the publication, dependency, listener, or trace-state cost of a companion; other packages record the omission reason in their README. - Compositions that mount the diagnostics can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. -- One selected executable contribution adds one child fiber and its listener/state cost; a selected empty contribution has no listener or trace-state cost, while filtered registrations retain only name ownership. +- One selected contribution adds one child fiber and its listener/state cost, while filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. -- Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. +- Ordinary Vitest roots install the owning test package's selected companion when published; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. - Session storage validation, snapshotting, freezing, cited source-event validation, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 81e8e0361e..050377a8d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -10,7 +10,7 @@ Status: implemented 选择启用诊断的部署还需要比“是否加载一个插件”更细的控制。这类组合会携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR(热模块替换)下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 -包所有权还必须覆盖完整。若没有机械化的仓库规则,新包可能遗漏伴随插件、依赖或发布配置,并一直不会进入诊断范围,直到维护者发现这一缺口。 +已发布的包所有权必须机械完整。若没有仓库规则,包可能暴露不完整的 companion、依赖或发布映射,并一直保持损坏,直到维护者发现;不发布 companion 的包则必须在 README 中保留可评审的原因。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名。如果所有者具备有意义的事件或可变数据关系,companion 就检查该关系;否则空 installer 必须携带该所有者专属的说明。后续的[运行时约定 Agent Note](2026-07-19-package-invariant-runtime-contracts.zh.md) 禁止生成的所有权占位符和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +只有拥有可独立观察的事件或可变数据关系时,工作区包才发布 `./invariant` 伴随插件;该 companion 会注册自己完整且准确的 npm 包名。没有该关系的包会省略 companion 与发布接线,并在 README 中记录原因;[运行时约定 Agent Note](2026-07-19-package-invariant-runtime-contracts.zh.md) 与[省略决策](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)禁止生成占位符、空 installer 和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | 作用域事件载体的存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者提供了首批有状态检查。后续运行时约定决策为另外十七个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者提供了首批有状态检查。后续所有者会为真实事件或可变数据关系增加 companion,没有该关系的包则省略 companion 并记录原因。每个已发布伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、没有解释的空 installer、缺少或不使用失败报告器的非空 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 +`verify-package-invariants` 会发现每个工作区包,接受完整省略,并拒绝不完整的 companion 接线、生成标记、空 installer、缺少或不使用失败报告器的 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏已发布伴随入口的自定义构建配置。 ### 作用域事件语义映射 @@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准主干转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node 冒烟测试覆盖编译子路径 export。作用域事件新鲜度门禁会重新执行语义 Program 分析。 -每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并添加当前测试包的伴随插件。一个完整拓扑会一次挂载所有包的伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用 manifest(元数据清单)中的包名,而不是只检查源码文本。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并在当前测试包存在伴随插件时添加它。一个完整拓扑会一次挂载所有已发布伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个已发布伴随插件的 `apply` 函数,并验证它调用 `register` 时使用 manifest(元数据清单)中的包名,而不是只检查源码文本。 ## 考虑过的替代方案 @@ -96,10 +96,10 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都承担 companion 的发布与依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。 +- 只有具备有意义运行时关系的所有者才承担 companion 的发布、依赖、listener 或 trace 状态成本;其他包在 README 中记录省略原因。 - 挂载诊断的组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 -- 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。 +- 每个选中贡献增加一个子 fiber 及其 listener/状态成本,被过滤注册则只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 -- 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 +- 当前测试包发布伴随插件时,普通 Vitest 根上下文会安装其中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 - 会话存储验证、快照、冻结、引用的源事件验证与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index a1f574e789..0e4340ea02 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 4b9c2f224e36fb97f798492c999726eb55bad214 -2026-08-10-remote-event-delivery.zh.md: 4ca1f8c244df4e985d1223b6c468b450be943abf +2026-08-10-remote-event-delivery.md: 1706cc525bd7707874353a6658ec2f52597044a9 +2026-08-10-remote-event-delivery.zh.md: ac79642e90791892870c8920e02867faea180b07 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 4b9c2f224e..1706cc525b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -194,4 +194,4 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp - **Test-side mirrors can drift.** No mechanism compares mirrored Client constants under `apps/web/tests` with their source. Drift instead produces a selector mismatch. `apps/web/tests/README.md` records the review rule; a grep-level gate is deliberately omitted. - **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending. - **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee. -- **The invariant companion intentionally has no runtime check.** A prior revision asserted delivery form on the live event bus, coupling the companion to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, so the companion is an explained empty installer. +- **The package intentionally publishes no invariant companion.** A prior revision asserted delivery form on the live event bus, coupling diagnostics to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, and the package README records why no independent runtime relation remains. diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 4ca1f8c244..ac79642e90 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -196,4 +196,4 @@ Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 - **测试侧镜像值可能漂移**:没有任何机制核对 `apps/web/tests` 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 `apps/web/tests/README.md`,由 review 守;grep 级门禁经评估后刻意不做。 - **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 -- **invariant companion 不做运行期检查**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 `TypertForwardableEvent` 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。 +- **本包不发布 invariant companion**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的 `TypertForwardableEventEntry` 断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。 diff --git a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml index b175565b1d..39748610f6 100644 --- a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.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-26-inspector-execution-realms-and-protocol-planes.md -2026-08-26-inspector-execution-realms-and-protocol-planes.md: e8bff0661d2d0c86c216b0a18e2feb7a2c786709 -2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 2db6307d1bfc536ac5e8b0f5d6f03e4cfe334989 +2026-08-26-inspector-execution-realms-and-protocol-planes.md: 1c274bda4cb10458ddc9ecec846b6355fb811c2f +2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 49fde96a3b0acc5d9621b1ca4cfd1546ca970931 diff --git a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md index e8bff0661d..1c274bda4c 100644 --- a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md @@ -16,7 +16,7 @@ The [cross-realm CDP inspector decision](2026-08-23-cross-realm-cdp-inspector.md Top-level source directories identify execution ownership. `client/` contains only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client belongs under `worker/`, not `client/`. -The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling. +The repository-required `src/index.ts` discovery entry is the only root-level source exception in this package. It exposes the Host package entry and its service type, contains no Inspector runtime implementation, and remains at a fixed path for repository tooling. The package publishes no invariant companion because its relationships fail in their owning wire, generation, Worker, or CDP operations, as recorded in the README. ```text src/ diff --git a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md index 2db6307d1b..49fde96a3b 100644 --- a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md @@ -16,7 +16,7 @@ Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、 顶层源码目录标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。 -仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。 +仓库要求的 `src/index.ts` 发现入口是本包唯一的源码根目录例外。它暴露 Host package entry 及其 service type,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。本包不发布 invariant companion,因为其关系会在所属 wire、generation、Worker 或 CDP 操作中失败,原因记录在 README 中。 ```text src/ diff --git a/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.i18n.yaml new file mode 100644 index 0000000000..3edddd4cd5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.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/simplification/2026-08-28-omit-unneeded-invariant-companions.md +2026-08-28-omit-unneeded-invariant-companions.md: 981f9641c5a1ccc6755277c5bc5940851f80a310 +2026-08-28-omit-unneeded-invariant-companions.zh.md: 8d3d83ea2539e87f68edd85afbf965c459d5fb84 diff --git a/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md b/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md new file mode 100644 index 0000000000..981f9641c5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md @@ -0,0 +1,48 @@ +# Agent Note: Omit invariant companions without independent observations + +Status: implemented + +English | [中文](2026-08-28-omit-unneeded-invariant-companions.zh.md) + +## Problem + +The package invariant rule required every workspace package to publish `./invariant`, including packages with no runtime relationship to check. The current workspace had 207 explained-empty companions, each carrying a source file, public export, publication entry, invariant-only dependencies or TypeScript references, build wiring, and registration tests. That machinery expressed a negative conclusion without adding a runtime assertion. + +The `dsh-host-webserver` companion exposed the same problem in executable form. It registered and disposed synthetic reserved routes on plugin lifecycle events, then called the same service operations again to detect residue. The probe had no independently produced observation: it mutated and inspected one route table through the implementation it claimed to verify, while real route and HMR tests already covered duplicate rejection and disposer symmetry. + +## Decision + +### Independent observations justify publication + +A package publishes `./invariant` only when it can compare observations that may independently diverge. Qualifying relationships include cross-event lifecycle, ordering, identity, or pairing protocols; events compared with authoritative mutable state; output assembled from multiple producers or adapters; and durable data later folded or consumed by a different operation. + +Service or method presence, plugin metadata or effects, fixed pure examples, and probes that call the same mutation they claim to verify remain type, load, unit, or integration-test concerns. Parser and config input, model or tool JSON, durable files, worker and process messages, and wire input remain validated at their owning input operation. + +The `dsh-time-context` companion remains published. Its check compares the plugin-produced context message with independently owned current-turn user-message provenance and durable event time, so attribution, turn position, and elapsed-time relations can diverge even when the formatter itself is correct. + +### Omission is explicit in the package README + +A package without a qualifying relationship omits `src/invariant.ts`, the `./invariant` export, `lib/invariant.js` publication, invariant-only dependencies and TypeScript references, build entries, and companion-only tests. Its English and Chinese package READMEs state that no companion is published and give the package-specific reason. Empty installers are rejected because source absence plus the README explanation now expresses the decision directly. + +`verify-package-invariants` scans every package. It requires a package-specific omission reason in the English README, rejects partial export, publication, or companion build wiring, rejects empty installers, and applies the registration, Loader namespace, reporter-use, dependency, reference, and build checks to every published companion. The Vitest host mounts the current package companion only when one exists, while topology and built-artifact checks enumerate the published set. + +### Audit result + +The repository-wide audit removed the 207 explained-empty companions and the synthetic `dsh-host-webserver` companion, leaving 39 checks with independent observations. The retained set includes cross-event protocols such as session, command, approval, workflow, and hook lifecycles; event-to-state checks such as settings, storage-domain, Workspace, client modules, and slots; multi-producer assembly such as system prompt and time context; and durable data consumed by projections or policy state such as todo, plan mode, and sandbox mode. + +Existing package behavior tests remain responsible for omitted relationships, including webserver route registration and HMR disposal. Product behavior and root package entrypoints do not change; the omitted `./invariant` subpaths are removed under the repository's pre-release compatibility stance. + +## Alternatives considered + +- **Keep explained empty companions.** Rejected because a source file, public subpath, dependency edges, build output, and tests are disproportionate machinery for saying that no check exists; the package README records that conclusion directly. +- **Keep the webserver probe as a teardown sentinel.** Rejected because it mutates a reserved route on unrelated lifecycle events and verifies only the service method it invokes. Real routing and HMR tests exercise the behavior without production diagnostic effects. +- **Treat every producer-format parser as self-validation.** Rejected because a parser can compare independent provenance, timing, or durable history even when one producer owns the text. `dsh-time-context` qualifies because its message is checked against current-turn user messages and durable event time; a same-writer payload round trip alone would not qualify. +- **Require every package with mutable private state to publish a companion.** Rejected because private state without an independent event or second data source cannot be checked without duplicating the implementation or exposing new API solely for diagnostics. + +## Consequences + +- Packages with meaningful checks retain independently loadable, filterable, package-attributed companions. +- Packages without checks have no invariant source, public subpath, build artifact, or invariant-only dependency burden, and their READMEs preserve the reason. +- Adding a mutable relationship or consumed event protocol requires revisiting omission, updating the README, and adding a focused companion with a negative test. +- The invariant service configuration, ownership uniqueness, child-fiber lifecycle, filtering, rollback, disposal, and HMR contracts remain unchanged. +- The earlier [meaningful runtime-contract decision](../architecture/2026-07-19-package-invariant-runtime-contracts.md) remains authoritative for semantic check quality; this decision supersedes its exhaustive publication and explained-empty form. diff --git a/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md b/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md new file mode 100644 index 0000000000..8d3d83ea25 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md @@ -0,0 +1,48 @@ +# Agent Note(agent 决策记录):没有独立观察时省略不变量伴生入口 + +Status: implemented + +[English](2026-08-28-omit-unneeded-invariant-companions.md) | 中文 + +## 问题 + +包不变量规则曾要求每个工作区包都发布 `./invariant`,包括没有运行时关系可检查的包。当前工作区有 207 个带说明的空伴生入口,每个入口都带来源文件、公共导出、发布项、仅供不变量使用的依赖或 TypeScript 引用、构建接线与注册测试。这套机制只表达否定结论,没有增加运行时断言。 + +`dsh-host-webserver` 伴生入口以可执行形式暴露了同一问题。它会在插件生命周期事件上注册并释放合成保留路由,再次调用同一组服务操作来检测残留。该探针没有独立产生的观察:它通过自己要验证的实现修改并检查同一张路由表,而真实路由与 HMR 测试已经覆盖重复拒绝和 disposer 对称性。 + +## 决策 + +### 独立观察是发布条件 + +只有当包能够比较可能独立产生分歧的观察时,才发布 `./invariant`。符合条件的关系包括跨事件生命周期、顺序、身份或配对协议;事件与权威可变状态的对照;多个生产方或 adapter 组装的输出;以及由另一个操作后续折叠或消费的持久数据。 + +服务或方法是否存在、插件 metadata 或 effect、固定纯函数示例,以及调用同一变更操作来验证该操作的探针,仍属于类型、加载、单元或集成测试。parser 与 config 输入、模型或工具 JSON、持久文件、worker 与进程消息和 wire 输入,仍在拥有其输入的操作处校验。 + +`dsh-time-context` 伴生入口继续发布。它把插件产生的 context message 与独立拥有的当前轮用户消息 provenance 和持久事件时间进行对照,因此即使 formatter 本身正确,attribution、轮次位置与 elapsed-time 关系仍可能产生分歧。 + +### 在包 README 中明确省略 + +没有符合条件的关系时,包会省略 `src/invariant.ts`、`./invariant` 导出、`lib/invariant.js` 发布、仅供不变量使用的依赖与 TypeScript 引用、构建入口和伴生入口专用测试。中英文包 README 会说明不发布伴生入口,并记录该包的具体原因。仓库会拒绝空 installer,因为不存在源文件加 README 说明可以直接表达该决策。 + +`verify-package-invariants` 会扫描每个包。它要求英文 README 记录包级省略原因,拒绝不完整的导出、发布或伴生入口构建接线,拒绝空 installer,并对每个已发布伴生入口执行注册、Loader namespace、reporter 使用、依赖、引用与构建检查。Vitest Host 只在当前包存在伴生入口时挂载它,拓扑与构建产物检查则枚举已发布集合。 + +### 审计结果 + +全仓库审计删除了 207 个带说明的空伴生入口和合成的 `dsh-host-webserver` 伴生入口,留下 39 项比较独立观察的检查。保留项包括 session、command、approval、workflow 与 hook 生命周期等跨事件协议;settings、storage-domain、Workspace、client modules 与 slots 等事件到状态检查;system prompt 与 time context 等多生产方组装检查;以及 todo、plan mode 与 sandbox mode 等由 projection 或 policy state 消费的持久数据。 + +被省略关系继续由现有包行为测试负责,包括 webserver 路由注册与 HMR 释放。产品行为与包根入口不变;被省略的 `./invariant` 子路径按照仓库的预发布兼容策略移除。 + +## 考虑过的替代方案 + +- **保留带说明的空伴生入口。** 不采用:源文件、公共子路径、依赖边、构建输出和测试是一套过于繁重的机制,不应只用来表达不存在检查;包 README 可以直接记录该结论。 +- **把 webserver 探针保留为清理 sentinel。** 不采用:它会在无关生命周期事件上修改保留路由,并且只验证自己调用的服务方法。真实路由与 HMR 测试可以在没有生产诊断 effect 的情况下覆盖该行为。 +- **把每个生产方格式 parser 都视为自校验。** 不采用:即使文本只有一个生产方,parser 仍可能对照独立 provenance、时间或持久历史。`dsh-time-context` 符合条件,因为其消息会与当前轮用户消息和持久事件时间对照;只对同一写入方的 payload 做往返检查不符合条件。 +- **要求每个拥有私有可变状态的包都发布伴生入口。** 不采用:没有独立事件或第二数据源的私有状态只能通过重复实现来检查,或者需要专门为诊断暴露新 API。 + +## 后果 + +- 拥有有意义检查的包保留可独立加载、过滤并归属到包的伴生入口。 +- 没有检查的包不再承担不变量源文件、公共子路径、构建产物或仅供不变量使用的依赖,其 README 会保留原因。 +- 新增可变关系或被消费的事件协议时,必须重新审视省略结论、更新 README,并添加带负向测试的聚焦伴生入口。 +- 不变量服务的配置、归属唯一性、子 fiber 生命周期、过滤、回滚、dispose 与 HMR 约定保持不变。 +- 早期的[有意义运行时约定决策](../architecture/2026-07-19-package-invariant-runtime-contracts.zh.md)仍是语义检查质量的权威依据;本决策取代其中的穷尽发布与带说明空入口形式。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 510e01f3d5..9fc471fd78 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -23,7 +23,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home. 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [subsystems](../../../docs/subsystems/README.md) page and any `type-equiv` entry. Internal types need no catalog entry. 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)). +5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship with independent observations at the point where that package can observe it; service or method presence, plugin metadata or effects, fixed pure examples, and probes that call the same operation they claim to verify belong in load, behavior, or unit tests. When no plausible relationship exists, require the package to omit the companion and publication wiring and record its package-specific reason in the README. Reject empty installers and invented checks ([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. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 3d09af1d54..66cde4c08f 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -30,6 +30,10 @@ A strong simplification removes, folds, or demotes something real and has clear Thin candidates are not enough for an Agent Note: deleting one typo, running `knip` once, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof. +### Audit invariant companions + +Treat an invariant companion as useful only when it compares independently produced observations that can diverge. Remove empty installers and checks that merely inspect service presence, plugin metadata, fixed examples, or the result of calling the same mutation they claim to verify. For every omission, remove the export, build entry, invariant-only compiler reference or dependency, and companion-only test, then record the package-specific reason in both package READMEs. Keep a companion when it compares distinct event producers, durable history, or independently mutable data, even if the package also validates inputs synchronously. + ## Survey Broadly Use parallel subagents when the user asks for breadth or many candidates. Give each agent a domain and require evidence, not guesses. Useful domains: diff --git a/AGENTS.md b/AGENTS.md index 0403610833..9dd5093b52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - Every npm package is `@deepseek-ai/dsh-`; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). +- **Runtime invariants assert owned relationships.** Publish `./invariant` only when independent observations can diverge. Otherwise omit its source and wiring and state the package-specific reason in the README; empty installers and checks of service presence, plugin metadata, effects, or fixed examples are invalid ([package invariant rules](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. Every `SessionEventMap` member is required-on-read: builds that do not know its type refuse the log; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index cf5c3ea52e..3b2a075dff 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: 24ed4b92daae6d06a862618e398605fce7063f4f -event-producer-consumer.zh.md: af440aa4155b65a211b7fdc9bce232a264bbe3a7 +event-producer-consumer.md: 46d6f599227de9800c0d603581689e2cb7cd2d84 +event-producer-consumer.zh.md: 8bec2340b2d84f443fd20b7ad4b163e2d4597e47 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24ed4b92da..46d6f59922 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -78,7 +78,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-log-deepseek`](../packages/session/session-log-deepseek), [`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), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | +| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index af440aa415..8bec2340b2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -80,7 +80,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-log-deepseek`](../packages/session/session-log-deepseek), [`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), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | +| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 78224963c5..d26d8d9875 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: 6e511edc958cdcfe9f84d495193b6839b34d112e -module-graph.zh.md: 107a1695b755ddef67d62b69c84e846cf4b5c09b +module-graph.md: f99d0859ab335790b75a10c3ff34528ec7928850 +module-graph.zh.md: d677556c6a8eaaa1ac377eb13521ccbc951f45c0 diff --git a/docs/module-graph.md b/docs/module-graph.md index 6e511edc95..f99d0859ab 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -356,71 +356,26 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end - pkg_atomic_write --> pkg_invariants - pkg_brand --> pkg_invariants - pkg_home_paths --> pkg_invariants - pkg_launch_environment --> pkg_invariants - pkg_native_command --> pkg_invariants - pkg_output_retention --> pkg_invariants - pkg_timeout --> pkg_invariants - pkg_util_crypto --> pkg_invariants - pkg_util_workspace_path --> pkg_invariants - pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants - pkg_cmdline --> pkg_invariants - pkg_acp_app --> pkg_invariants - pkg_base --> pkg_invariants - pkg_sdk_app --> pkg_invariants - pkg_sdk_minimal --> 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 - pkg_code_runtime_python --> pkg_invariants - pkg_e2b --> pkg_invariants - pkg_experimental_agent_team_profile --> pkg_invariants - pkg_experimental_agent_team_web_profile --> 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 - pkg_host_webserver --> pkg_invariants - 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 - pkg_typert_registry --> pkg_invariants pkg_attachment --> pkg_brand - pkg_attachment --> pkg_invariants pkg_client_modules --> pkg_host_webserver pkg_client_modules --> pkg_invariants + pkg_client_ui_renderer --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout pkg_host_plugin_inventory --> pkg_brand - pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths - pkg_anonymous_user_id --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage - pkg_storage_json --> pkg_invariants pkg_storage_json --> pkg_storage - pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage - pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout - pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm --> pkg_attachment pkg_llm --> pkg_brand @@ -429,18 +384,15 @@ flowchart TD pkg_llm --> pkg_typert_protocol pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_attachment_local --> pkg_invariants pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths - pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_inspector --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -449,58 +401,43 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants pkg_skill --> pkg_llm pkg_skill --> pkg_scope - pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm pkg_lsp --> pkg_brand - pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_web_fetch_http --> pkg_invariants pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_launch_environment pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand - pkg_spill --> pkg_invariants pkg_spill --> pkg_llm pkg_spill --> pkg_session pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt pkg_code_runtime_worker_thread --> pkg_code_runtime - pkg_code_runtime_worker_thread --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_session pkg_code_runtime_worker_thread --> pkg_timeout - pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt - pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_log_deepseek --> pkg_deepseek_llm_api_extensions pkg_session_log_deepseek --> pkg_invariants pkg_session_log_deepseek --> pkg_session pkg_session_persistence --> pkg_brand - pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session pkg_session_persistence --> pkg_timeout - pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_settings --> pkg_session - pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_agent --> pkg_invariants pkg_agent --> pkg_llm @@ -513,39 +450,30 @@ flowchart TD pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm pkg_message_feedback --> pkg_session pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence - pkg_session_persistence_sqlite --> pkg_invariants pkg_session_persistence_sqlite --> pkg_llm pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence - pkg_session_projection_cache --> pkg_invariants pkg_session_projection_cache --> pkg_session pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain - pkg_session_stats --> pkg_invariants pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection pkg_settings_file --> pkg_atomic_write pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings pkg_shell --> pkg_subprocess @@ -563,7 +491,6 @@ flowchart TD pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions pkg_llm_deepseek --> pkg_fs pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings @@ -572,7 +499,6 @@ flowchart TD pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_fs - pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_launch_environment pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings @@ -585,7 +511,6 @@ flowchart TD pkg_llm_retry --> pkg_session_projection pkg_llm_retry --> pkg_timeout pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm pkg_agent_default_model --> pkg_settings pkg_goal --> pkg_agent @@ -597,16 +522,12 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants pkg_skill_filesystem --> pkg_fs pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants pkg_skill_filesystem --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_settings @@ -615,20 +536,17 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_file_reference --> pkg_agent - pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_time_context --> pkg_session_projection pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection pkg_tmux_context --> pkg_shell pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_attachment pkg_commands --> pkg_brand @@ -645,7 +563,6 @@ flowchart TD pkg_user_approval --> pkg_session pkg_user_approval --> pkg_system_prompt pkg_user_questions --> pkg_agent - pkg_user_questions --> pkg_invariants pkg_user_questions --> pkg_llm pkg_user_questions --> pkg_scope pkg_jobs --> pkg_agent @@ -654,7 +571,6 @@ flowchart TD pkg_jobs --> pkg_session pkg_lsp_stdio --> pkg_brand pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp pkg_lsp_stdio --> pkg_subprocess @@ -666,7 +582,6 @@ flowchart TD pkg_sandbox_policy --> pkg_session_projection pkg_sandbox_policy --> pkg_system_prompt pkg_session_telemetry --> pkg_agent - pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session pkg_session_title --> pkg_agent pkg_session_title --> pkg_brand @@ -674,21 +589,17 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_settings pkg_bash_local --> pkg_shell pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants pkg_pwsh_local --> pkg_settings pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand - pkg_terminal --> pkg_invariants pkg_loader_smoke --> pkg_agent - pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session pkg_workflow --> pkg_agent @@ -706,7 +617,6 @@ flowchart TD pkg_tools --> pkg_user_approval pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal - pkg_command_goal --> pkg_invariants pkg_command_goal --> pkg_llm pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal @@ -715,12 +625,10 @@ flowchart TD pkg_goal_round_driver --> pkg_session pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model - pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session pkg_compaction --> pkg_brand @@ -730,7 +638,6 @@ flowchart TD pkg_compaction --> pkg_session pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands - pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry pkg_permission_presets --> pkg_commands @@ -743,27 +650,22 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_user_approval pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_invariants pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_bash_sandbox --> pkg_sandbox_policy pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants pkg_pwsh_sandbox --> pkg_pwsh_local pkg_pwsh_sandbox --> pkg_sandbox pkg_pwsh_sandbox --> pkg_sandbox_policy pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent - pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session @@ -771,7 +673,6 @@ flowchart TD pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction - pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_llm_retry pkg_token_meter --> pkg_session @@ -786,11 +687,9 @@ flowchart TD pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools - pkg_agent_tool_presentation --> pkg_invariants pkg_agent_tool_presentation --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal - pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_session_projection @@ -798,7 +697,6 @@ flowchart TD pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs - pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox pkg_tool_fs --> pkg_sandbox_policy @@ -806,7 +704,6 @@ flowchart TD pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools pkg_tool_fs --> pkg_user_approval - pkg_tool_fs_search --> pkg_invariants pkg_tool_fs_search --> pkg_llm pkg_tool_fs_search --> pkg_output_retention pkg_tool_fs_search --> pkg_session @@ -816,21 +713,17 @@ flowchart TD pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs - pkg_tool_str_replace_editor --> pkg_invariants pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent - pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web - pkg_spill_policy --> pkg_invariants pkg_spill_policy --> pkg_llm pkg_spill_policy --> pkg_output_retention pkg_spill_policy --> pkg_session @@ -852,7 +745,6 @@ flowchart TD pkg_plan_mode --> pkg_user_questions pkg_hooks_codex --> pkg_agent pkg_hooks_codex --> pkg_hook_protocol - pkg_hooks_codex --> pkg_invariants pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence @@ -860,54 +752,44 @@ flowchart TD pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction - pkg_command_compact --> pkg_invariants pkg_agent_instructions --> pkg_agent pkg_agent_instructions --> pkg_fs pkg_agent_instructions --> pkg_home_paths - pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_file_reference_local --> pkg_agent pkg_file_reference_local --> pkg_file_reference - pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand - pkg_cordis_host_runner --> pkg_invariants pkg_cordis_host_runner --> pkg_llm pkg_cordis_host_runner --> pkg_scope pkg_cordis_host_runner --> pkg_session pkg_cordis_host_runner --> pkg_tools pkg_cordis_host_runner --> pkg_typert_protocol pkg_repeat_tool_reminder --> pkg_agent - pkg_repeat_tool_reminder --> pkg_invariants pkg_repeat_tool_reminder --> pkg_tools - pkg_tool_call_timeout_policy --> pkg_invariants pkg_tool_call_timeout_policy --> pkg_llm pkg_tool_call_timeout_policy --> pkg_timeout pkg_tool_call_timeout_policy --> pkg_tools pkg_tool_ask_user --> pkg_agent - pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions pkg_tool_jobs --> pkg_agent - pkg_tool_jobs --> pkg_invariants pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm pkg_tool_jobs --> pkg_output_retention pkg_tool_jobs --> pkg_system_prompt pkg_tool_jobs --> pkg_tools - pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp pkg_tool_lsp --> pkg_system_prompt pkg_tool_lsp --> pkg_timeout pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_attachment - pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_scope pkg_mcp_client --> pkg_subprocess @@ -932,44 +814,36 @@ flowchart TD pkg_schedule --> pkg_session_persistence pkg_schedule --> pkg_tools pkg_session_checkpoint_policy --> pkg_agent - pkg_session_checkpoint_policy --> pkg_invariants pkg_session_checkpoint_policy --> pkg_llm pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools pkg_session_telemetry_otel --> pkg_anonymous_user_id pkg_session_telemetry_otel --> pkg_command_feedback - pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry - pkg_session_title_all_prompts_llm --> pkg_invariants pkg_session_title_all_prompts_llm --> pkg_llm pkg_session_title_all_prompts_llm --> pkg_session pkg_session_title_all_prompts_llm --> pkg_session_title pkg_session_title_all_prompts_llm --> pkg_session_title_llm - pkg_session_title_first_prompt_llm --> pkg_invariants pkg_session_title_first_prompt_llm --> pkg_llm pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm pkg_shell_env --> pkg_home_paths - pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools pkg_tool_bash_persistent --> pkg_agent - pkg_tool_bash_persistent --> pkg_invariants pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools pkg_tool_pwsh_persistent --> pkg_agent - pkg_tool_pwsh_persistent --> pkg_invariants pkg_tool_pwsh_persistent --> pkg_terminal pkg_tool_pwsh_persistent --> pkg_timeout pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent - pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs pkg_tool_terminal --> pkg_llm pkg_tool_terminal --> pkg_output_retention @@ -977,14 +851,12 @@ flowchart TD pkg_tool_terminal --> pkg_terminal pkg_tool_terminal --> pkg_tools pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions - pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session pkg_tool_workflow --> pkg_agent @@ -997,10 +869,8 @@ flowchart TD pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions - pkg_plugin_package_inventory_deepseek --> pkg_invariants pkg_plugin_package_inventory_deepseek --> pkg_session pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence @@ -1010,7 +880,6 @@ flowchart TD pkg_session_query --> pkg_tool_todo pkg_acp --> pkg_agent pkg_acp --> pkg_attachment - pkg_acp --> pkg_invariants pkg_acp --> pkg_llm pkg_acp --> pkg_mcp_client pkg_acp --> pkg_session @@ -1019,12 +888,10 @@ flowchart TD pkg_acp --> pkg_user_approval pkg_api_settings_controller --> pkg_agent_presets pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants pkg_api_settings_controller --> pkg_native_command pkg_api_settings_controller --> pkg_session pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol - pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt pkg_client_connection --> pkg_attachment @@ -1033,26 +900,22 @@ flowchart TD pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_invariants pkg_tool_cordis --> pkg_llm pkg_tool_cordis --> pkg_scope pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox @@ -1063,7 +926,6 @@ flowchart TD pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs pkg_tool_pwsh --> pkg_llm pkg_tool_pwsh --> pkg_sandbox @@ -1101,12 +963,10 @@ flowchart TD pkg_subagent --> pkg_tools pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval - pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query pkg_tool_session_query --> pkg_agent - pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session pkg_tool_session_query --> pkg_session_projection @@ -1117,19 +977,16 @@ flowchart TD pkg_api_gateway --> pkg_brand pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction pkg_compaction_basic --> pkg_compaction_tool_result_pruner - pkg_compaction_basic --> pkg_invariants pkg_compaction_basic --> pkg_llm pkg_compaction_basic --> pkg_session pkg_compaction_basic --> pkg_token_meter pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction - pkg_session_reference --> pkg_invariants pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session @@ -1163,36 +1020,29 @@ flowchart TD pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants pkg_webhook_github --> pkg_session pkg_webhook_github --> pkg_webhook pkg_subagent_acp --> pkg_agent - pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess pkg_subagent_acp --> pkg_timeout - pkg_subagent_claude_code --> pkg_invariants pkg_subagent_claude_code --> pkg_llm pkg_subagent_claude_code --> pkg_session pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout - pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_session pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess pkg_subagent_codex --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent - pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm pkg_subagent_in_process_driver --> pkg_session pkg_subagent_in_process_driver --> pkg_subagent @@ -1209,19 +1059,16 @@ flowchart TD pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt pkg_tool_subagent --> pkg_tools - pkg_tool_subagent_control --> pkg_invariants pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session pkg_tool_subagent_control --> pkg_subagent pkg_tool_subagent_control --> pkg_tools - pkg_tool_subagent_report --> pkg_invariants pkg_tool_subagent_report --> pkg_llm pkg_tool_subagent_report --> pkg_subagent pkg_tool_subagent_report --> pkg_system_prompt pkg_tool_subagent_report --> pkg_tools pkg_hooks_claude_code --> pkg_agent pkg_hooks_claude_code --> pkg_hook_protocol - pkg_hooks_claude_code --> pkg_invariants pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence @@ -1236,7 +1083,6 @@ flowchart TD pkg_api_session_controller --> pkg_brand pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_file_reference - pkg_api_session_controller --> pkg_invariants pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1256,7 +1102,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection pkg_api_workspace_controller --> pkg_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol @@ -1270,12 +1115,10 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol - pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent pkg_tool_ralph --> pkg_agent - pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm pkg_tool_ralph --> pkg_subagent pkg_tool_ralph --> pkg_system_prompt @@ -1283,18 +1126,15 @@ flowchart TD pkg_tool_ralph --> pkg_workflow pkg_workflow_worker_thread --> pkg_agent pkg_workflow_worker_thread --> pkg_brand - pkg_workflow_worker_thread --> pkg_invariants pkg_workflow_worker_thread --> pkg_llm pkg_workflow_worker_thread --> pkg_session pkg_workflow_worker_thread --> pkg_subagent pkg_workflow_worker_thread --> pkg_tools pkg_workflow_worker_thread --> pkg_workflow pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_invariants pkg_subagent_fork_in_process --> pkg_session pkg_subagent_fork_in_process --> pkg_subagent pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_api_remotes --> pkg_agent_presets @@ -1308,7 +1148,6 @@ flowchart TD pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session @@ -1319,21 +1158,17 @@ flowchart TD pkg_api_remotes --> pkg_user_questions 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_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_invariants pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools - pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session pkg_sdk_jsonrpc_server --> pkg_agent pkg_sdk_jsonrpc_server --> pkg_attachment - pkg_sdk_jsonrpc_server --> pkg_invariants pkg_sdk_jsonrpc_server --> pkg_llm pkg_sdk_jsonrpc_server --> pkg_llm_deepseek pkg_sdk_jsonrpc_server --> pkg_scope @@ -1341,7 +1176,6 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants pkg_subagent_dsh_sdk --> pkg_llm pkg_subagent_dsh_sdk --> pkg_sdk_client pkg_subagent_dsh_sdk --> pkg_session @@ -1349,49 +1183,41 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_subprocess pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection - 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_ui_renderer pkg_client_locale --> pkg_client_ui_settings - pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_locale 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_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_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_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_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 - 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 @@ -1405,7 +1231,6 @@ flowchart TD 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 @@ -1423,7 +1248,6 @@ flowchart TD 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_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller @@ -1433,7 +1257,6 @@ flowchart TD 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_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path @@ -1447,7 +1270,6 @@ flowchart TD 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 @@ -1455,42 +1277,35 @@ flowchart TD 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_api_remotes 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 @@ -1500,7 +1315,6 @@ flowchart TD 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 @@ -1510,7 +1324,6 @@ flowchart TD 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_llm pkg_client_ui_trajectory --> pkg_session pkg_client_ui_trajectory --> pkg_tools @@ -1520,7 +1333,6 @@ flowchart TD 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 @@ -1533,7 +1345,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_session pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team - pkg_experimental_client_ui_agent_team --> pkg_invariants pkg_experimental_client_ui_agent_team --> pkg_session pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_client_ui_chat --> pkg_agent @@ -1551,7 +1362,6 @@ flowchart TD 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 @@ -1568,7 +1378,6 @@ flowchart TD 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_api_session_controller @@ -1576,7 +1385,6 @@ flowchart TD pkg_client_ui_reference --> pkg_client_locale 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_client_ui_reference --> pkg_util_workspace_path @@ -1587,7 +1395,6 @@ flowchart TD 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 @@ -1596,7 +1403,6 @@ flowchart TD 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_attachment pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale @@ -1605,7 +1411,6 @@ flowchart TD 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_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence pkg_session_log_export --> pkg_session_query @@ -1614,14 +1419,12 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory - pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_api_remotes 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 @@ -1633,7 +1436,6 @@ flowchart TD 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 @@ -1643,7 +1445,6 @@ flowchart TD 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 @@ -1655,7 +1456,6 @@ flowchart TD 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 @@ -1666,7 +1466,6 @@ flowchart TD 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 @@ -1676,7 +1475,6 @@ flowchart TD 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_tool --> pkg_util_workspace_path pkg_client_ui_workflow_run --> pkg_api_session_controller pkg_client_ui_workflow_run --> pkg_client_locale @@ -1684,7 +1482,6 @@ flowchart TD 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 @@ -1699,7 +1496,6 @@ flowchart TD 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_test_runtime --> pkg_subagent pkg_client_ui_skill --> pkg_api_remotes @@ -1709,7 +1505,6 @@ flowchart TD 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 @@ -1720,255 +1515,254 @@ flowchart TD 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 | | --- | --- | --- | +| [`atomic-write`](../packages/util/atomic-write) | `util` | — | +| [`brand`](../packages/util/brand) | `util` | — | +| [`home-paths`](../packages/util/home-paths) | `util` | — | +| [`launch-environment`](../packages/util/launch-environment) | `util` | — | +| [`native-command`](../packages/util/native-command) | `util` | — | +| [`output-retention`](../packages/util/output-retention) | `util` | — | +| [`timeout`](../packages/util/timeout) | `util` | — | +| [`util-crypto`](../packages/util/crypto) | `util` | — | +| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | +| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | +| [`cmdline`](../packages/boot/cmdline) | `boot` | — | +| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | +| [`base`](../packages/bundle/base) | `bundle` | — | +| [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | +| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | +| [`client-store`](../packages/client/store) | `client` | — | +| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — | +| [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | +| [`client-web`](../packages/client/web) | `client` | — | +| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | — | +| [`e2b`](../packages/e2b/e2b) | `e2b` | — | +| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | +| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | +| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | +| [`host-directory-picker`](../packages/host/directory-picker) | `host` | — | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — | +| [`host-webserver`](../packages/host/webserver) | `host` | — | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | -| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`launch-environment`](../packages/util/launch-environment) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — | +| [`storage`](../packages/storage/storage) | `storage` | — | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | — | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | +| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | +| [`typert-generator`](../packages/typert/generator) | `typert` | — | +| [`typert-protocol`](../packages/typert/protocol) | `typert` | — | +| [`typert-registry`](../packages/typert/registry) | `typert` | — | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | -| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `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) | -| [`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) | -| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`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) | -| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | -| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | +| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | +| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | -| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`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) | -| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`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), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`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) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | -| [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | -| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`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) | -| [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | +| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | +| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | +| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-stats`](../packages/session/session-stats) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings) | +| [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`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), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`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) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`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), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | +| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | +| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`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) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | +| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | +| [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | -| [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | +| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | +| [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | +| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | -| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`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-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`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-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`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) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`typert-protocol`](../packages/typert/protocol) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`typert-registry`](../packages/typert/registry) | +| [`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), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`typert-protocol`](../packages/typert/protocol) | | [`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-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | +| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`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), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`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-settings-controller`](../packages/api/settings-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), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`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) | -| [`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) | -| [`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), [`attachment`](../packages/attachment/attachment), [`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) | -| [`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-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-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), [`util-workspace-path`](../packages/util/workspace-path), [`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-remotes`](../packages/api/remotes), [`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), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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` | [`api-remotes`](../packages/api/remotes), [`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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`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-settings`](../packages/client/ui-settings), [`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), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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` | [`attachment`](../packages/attachment/attachment), [`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-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), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`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), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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), [`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-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-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), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`subagent`](../packages/subagent/subagent) | -| [`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) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| [`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-settings-controller`](../packages/api/settings-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), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`session`](../packages/core/session) | +| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`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), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `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) | +| [`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) | +| [`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) | +| [`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), [`settings`](../packages/settings/settings) | +| [`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) | +| [`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) | +| [`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), [`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), [`util-workspace-path`](../packages/util/workspace-path), [`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) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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), [`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), [`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) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace) | +| [`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) | +| [`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), [`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) | +| [`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), [`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), [`settings`](../packages/settings/settings) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`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-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`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), [`session`](../packages/core/session) | +| [`client-ui-reference`](../packages/client/ui-reference) | `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), [`file-reference`](../packages/context/file-reference), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`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), [`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) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`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-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`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), [`client-ui-trajectory`](../packages/client/ui-trajectory) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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), [`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), [`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), [`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-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), [`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-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), [`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), [`util-workspace-path`](../packages/util/workspace-path) | +| [`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), [`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), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`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), [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 107a1695b7..d677556c6a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -358,71 +358,26 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end - pkg_atomic_write --> pkg_invariants - pkg_brand --> pkg_invariants - pkg_home_paths --> pkg_invariants - pkg_launch_environment --> pkg_invariants - pkg_native_command --> pkg_invariants - pkg_output_retention --> pkg_invariants - pkg_timeout --> pkg_invariants - pkg_util_crypto --> pkg_invariants - pkg_util_workspace_path --> pkg_invariants - pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants - pkg_cmdline --> pkg_invariants - pkg_acp_app --> pkg_invariants - pkg_base --> pkg_invariants - pkg_sdk_app --> pkg_invariants - pkg_sdk_minimal --> 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 - pkg_code_runtime_python --> pkg_invariants - pkg_e2b --> pkg_invariants - pkg_experimental_agent_team_profile --> pkg_invariants - pkg_experimental_agent_team_web_profile --> 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 - pkg_host_webserver --> pkg_invariants - 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 - pkg_typert_registry --> pkg_invariants pkg_attachment --> pkg_brand - pkg_attachment --> pkg_invariants pkg_client_modules --> pkg_host_webserver pkg_client_modules --> pkg_invariants + pkg_client_ui_renderer --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout pkg_host_plugin_inventory --> pkg_brand - pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths - pkg_anonymous_user_id --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage - pkg_storage_json --> pkg_invariants pkg_storage_json --> pkg_storage - pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage - pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout - pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm --> pkg_attachment pkg_llm --> pkg_brand @@ -431,18 +386,15 @@ flowchart TD pkg_llm --> pkg_typert_protocol pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_attachment_local --> pkg_invariants pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths - pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_inspector --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -451,58 +403,43 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants pkg_skill --> pkg_llm pkg_skill --> pkg_scope - pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm pkg_lsp --> pkg_brand - pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_web_fetch_http --> pkg_invariants pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_launch_environment pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand - pkg_spill --> pkg_invariants pkg_spill --> pkg_llm pkg_spill --> pkg_session pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt pkg_code_runtime_worker_thread --> pkg_code_runtime - pkg_code_runtime_worker_thread --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_session pkg_code_runtime_worker_thread --> pkg_timeout - pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt - pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_log_deepseek --> pkg_deepseek_llm_api_extensions pkg_session_log_deepseek --> pkg_invariants pkg_session_log_deepseek --> pkg_session pkg_session_persistence --> pkg_brand - pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session pkg_session_persistence --> pkg_timeout - pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_settings --> pkg_session - pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_agent --> pkg_invariants pkg_agent --> pkg_llm @@ -515,39 +452,30 @@ flowchart TD pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm pkg_message_feedback --> pkg_session pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence - pkg_session_persistence_sqlite --> pkg_invariants pkg_session_persistence_sqlite --> pkg_llm pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence - pkg_session_projection_cache --> pkg_invariants pkg_session_projection_cache --> pkg_session pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain - pkg_session_stats --> pkg_invariants pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection pkg_settings_file --> pkg_atomic_write pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings pkg_shell --> pkg_subprocess @@ -565,7 +493,6 @@ flowchart TD pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions pkg_llm_deepseek --> pkg_fs pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings @@ -574,7 +501,6 @@ flowchart TD pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_fs - pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_launch_environment pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings @@ -587,7 +513,6 @@ flowchart TD pkg_llm_retry --> pkg_session_projection pkg_llm_retry --> pkg_timeout pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm pkg_agent_default_model --> pkg_settings pkg_goal --> pkg_agent @@ -599,16 +524,12 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants pkg_skill_filesystem --> pkg_fs pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants pkg_skill_filesystem --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_settings @@ -617,20 +538,17 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_file_reference --> pkg_agent - pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_time_context --> pkg_session_projection pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection pkg_tmux_context --> pkg_shell pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_attachment pkg_commands --> pkg_brand @@ -647,7 +565,6 @@ flowchart TD pkg_user_approval --> pkg_session pkg_user_approval --> pkg_system_prompt pkg_user_questions --> pkg_agent - pkg_user_questions --> pkg_invariants pkg_user_questions --> pkg_llm pkg_user_questions --> pkg_scope pkg_jobs --> pkg_agent @@ -656,7 +573,6 @@ flowchart TD pkg_jobs --> pkg_session pkg_lsp_stdio --> pkg_brand pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp pkg_lsp_stdio --> pkg_subprocess @@ -668,7 +584,6 @@ flowchart TD pkg_sandbox_policy --> pkg_session_projection pkg_sandbox_policy --> pkg_system_prompt pkg_session_telemetry --> pkg_agent - pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session pkg_session_title --> pkg_agent pkg_session_title --> pkg_brand @@ -676,21 +591,17 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_settings pkg_bash_local --> pkg_shell pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants pkg_pwsh_local --> pkg_settings pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand - pkg_terminal --> pkg_invariants pkg_loader_smoke --> pkg_agent - pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session pkg_workflow --> pkg_agent @@ -708,7 +619,6 @@ flowchart TD pkg_tools --> pkg_user_approval pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal - pkg_command_goal --> pkg_invariants pkg_command_goal --> pkg_llm pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal @@ -717,12 +627,10 @@ flowchart TD pkg_goal_round_driver --> pkg_session pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model - pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session pkg_compaction --> pkg_brand @@ -732,7 +640,6 @@ flowchart TD pkg_compaction --> pkg_session pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands - pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry pkg_permission_presets --> pkg_commands @@ -745,27 +652,22 @@ flowchart TD pkg_permission_presets --> pkg_shell pkg_permission_presets --> pkg_user_approval pkg_jobs_local --> pkg_agent - pkg_jobs_local --> pkg_invariants pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_bash_sandbox --> pkg_sandbox_policy pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants pkg_pwsh_sandbox --> pkg_pwsh_local pkg_pwsh_sandbox --> pkg_sandbox pkg_pwsh_sandbox --> pkg_sandbox_policy pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent - pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session @@ -773,7 +675,6 @@ flowchart TD pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction - pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_llm_retry pkg_token_meter --> pkg_session @@ -788,11 +689,9 @@ flowchart TD pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools - pkg_agent_tool_presentation --> pkg_invariants pkg_agent_tool_presentation --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal - pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_session_projection @@ -800,7 +699,6 @@ flowchart TD pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs - pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox pkg_tool_fs --> pkg_sandbox_policy @@ -808,7 +706,6 @@ flowchart TD pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools pkg_tool_fs --> pkg_user_approval - pkg_tool_fs_search --> pkg_invariants pkg_tool_fs_search --> pkg_llm pkg_tool_fs_search --> pkg_output_retention pkg_tool_fs_search --> pkg_session @@ -818,21 +715,17 @@ flowchart TD pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs - pkg_tool_str_replace_editor --> pkg_invariants pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent - pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web - pkg_spill_policy --> pkg_invariants pkg_spill_policy --> pkg_llm pkg_spill_policy --> pkg_output_retention pkg_spill_policy --> pkg_session @@ -854,7 +747,6 @@ flowchart TD pkg_plan_mode --> pkg_user_questions pkg_hooks_codex --> pkg_agent pkg_hooks_codex --> pkg_hook_protocol - pkg_hooks_codex --> pkg_invariants pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence @@ -862,54 +754,44 @@ flowchart TD pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction - pkg_command_compact --> pkg_invariants pkg_agent_instructions --> pkg_agent pkg_agent_instructions --> pkg_fs pkg_agent_instructions --> pkg_home_paths - pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_file_reference_local --> pkg_agent pkg_file_reference_local --> pkg_file_reference - pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand - pkg_cordis_host_runner --> pkg_invariants pkg_cordis_host_runner --> pkg_llm pkg_cordis_host_runner --> pkg_scope pkg_cordis_host_runner --> pkg_session pkg_cordis_host_runner --> pkg_tools pkg_cordis_host_runner --> pkg_typert_protocol pkg_repeat_tool_reminder --> pkg_agent - pkg_repeat_tool_reminder --> pkg_invariants pkg_repeat_tool_reminder --> pkg_tools - pkg_tool_call_timeout_policy --> pkg_invariants pkg_tool_call_timeout_policy --> pkg_llm pkg_tool_call_timeout_policy --> pkg_timeout pkg_tool_call_timeout_policy --> pkg_tools pkg_tool_ask_user --> pkg_agent - pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_questions pkg_tool_jobs --> pkg_agent - pkg_tool_jobs --> pkg_invariants pkg_tool_jobs --> pkg_jobs pkg_tool_jobs --> pkg_llm pkg_tool_jobs --> pkg_output_retention pkg_tool_jobs --> pkg_system_prompt pkg_tool_jobs --> pkg_tools - pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp pkg_tool_lsp --> pkg_system_prompt pkg_tool_lsp --> pkg_timeout pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_attachment - pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_scope pkg_mcp_client --> pkg_subprocess @@ -934,44 +816,36 @@ flowchart TD pkg_schedule --> pkg_session_persistence pkg_schedule --> pkg_tools pkg_session_checkpoint_policy --> pkg_agent - pkg_session_checkpoint_policy --> pkg_invariants pkg_session_checkpoint_policy --> pkg_llm pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools pkg_session_telemetry_otel --> pkg_anonymous_user_id pkg_session_telemetry_otel --> pkg_command_feedback - pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry - pkg_session_title_all_prompts_llm --> pkg_invariants pkg_session_title_all_prompts_llm --> pkg_llm pkg_session_title_all_prompts_llm --> pkg_session pkg_session_title_all_prompts_llm --> pkg_session_title pkg_session_title_all_prompts_llm --> pkg_session_title_llm - pkg_session_title_first_prompt_llm --> pkg_invariants pkg_session_title_first_prompt_llm --> pkg_llm pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm pkg_shell_env --> pkg_home_paths - pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools pkg_tool_bash_persistent --> pkg_agent - pkg_tool_bash_persistent --> pkg_invariants pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools pkg_tool_pwsh_persistent --> pkg_agent - pkg_tool_pwsh_persistent --> pkg_invariants pkg_tool_pwsh_persistent --> pkg_terminal pkg_tool_pwsh_persistent --> pkg_timeout pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent - pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs pkg_tool_terminal --> pkg_llm pkg_tool_terminal --> pkg_output_retention @@ -979,14 +853,12 @@ flowchart TD pkg_tool_terminal --> pkg_terminal pkg_tool_terminal --> pkg_tools pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions - pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session pkg_tool_workflow --> pkg_agent @@ -999,10 +871,8 @@ flowchart TD pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions - pkg_plugin_package_inventory_deepseek --> pkg_invariants pkg_plugin_package_inventory_deepseek --> pkg_session pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence @@ -1012,7 +882,6 @@ flowchart TD pkg_session_query --> pkg_tool_todo pkg_acp --> pkg_agent pkg_acp --> pkg_attachment - pkg_acp --> pkg_invariants pkg_acp --> pkg_llm pkg_acp --> pkg_mcp_client pkg_acp --> pkg_session @@ -1021,12 +890,10 @@ flowchart TD pkg_acp --> pkg_user_approval pkg_api_settings_controller --> pkg_agent_presets pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants pkg_api_settings_controller --> pkg_native_command pkg_api_settings_controller --> pkg_session pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol - pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt pkg_client_connection --> pkg_attachment @@ -1035,26 +902,22 @@ flowchart TD pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_invariants pkg_tool_cordis --> pkg_llm pkg_tool_cordis --> pkg_scope pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox @@ -1065,7 +928,6 @@ flowchart TD pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs pkg_tool_pwsh --> pkg_llm pkg_tool_pwsh --> pkg_sandbox @@ -1103,12 +965,10 @@ flowchart TD pkg_subagent --> pkg_tools pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval - pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query pkg_tool_session_query --> pkg_agent - pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session pkg_tool_session_query --> pkg_session_projection @@ -1119,19 +979,16 @@ flowchart TD pkg_api_gateway --> pkg_brand pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction pkg_compaction_basic --> pkg_compaction_tool_result_pruner - pkg_compaction_basic --> pkg_invariants pkg_compaction_basic --> pkg_llm pkg_compaction_basic --> pkg_session pkg_compaction_basic --> pkg_token_meter pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction - pkg_session_reference --> pkg_invariants pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session @@ -1165,36 +1022,29 @@ flowchart TD pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants pkg_webhook_github --> pkg_session pkg_webhook_github --> pkg_webhook pkg_subagent_acp --> pkg_agent - pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess pkg_subagent_acp --> pkg_timeout - pkg_subagent_claude_code --> pkg_invariants pkg_subagent_claude_code --> pkg_llm pkg_subagent_claude_code --> pkg_session pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout - pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_session pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess pkg_subagent_codex --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent - pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm pkg_subagent_in_process_driver --> pkg_session pkg_subagent_in_process_driver --> pkg_subagent @@ -1211,19 +1061,16 @@ flowchart TD pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt pkg_tool_subagent --> pkg_tools - pkg_tool_subagent_control --> pkg_invariants pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session pkg_tool_subagent_control --> pkg_subagent pkg_tool_subagent_control --> pkg_tools - pkg_tool_subagent_report --> pkg_invariants pkg_tool_subagent_report --> pkg_llm pkg_tool_subagent_report --> pkg_subagent pkg_tool_subagent_report --> pkg_system_prompt pkg_tool_subagent_report --> pkg_tools pkg_hooks_claude_code --> pkg_agent pkg_hooks_claude_code --> pkg_hook_protocol - pkg_hooks_claude_code --> pkg_invariants pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence @@ -1238,7 +1085,6 @@ flowchart TD pkg_api_session_controller --> pkg_brand pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_file_reference - pkg_api_session_controller --> pkg_invariants pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1258,7 +1104,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection pkg_api_workspace_controller --> pkg_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol @@ -1272,12 +1117,10 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol - pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent pkg_tool_ralph --> pkg_agent - pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm pkg_tool_ralph --> pkg_subagent pkg_tool_ralph --> pkg_system_prompt @@ -1285,18 +1128,15 @@ flowchart TD pkg_tool_ralph --> pkg_workflow pkg_workflow_worker_thread --> pkg_agent pkg_workflow_worker_thread --> pkg_brand - pkg_workflow_worker_thread --> pkg_invariants pkg_workflow_worker_thread --> pkg_llm pkg_workflow_worker_thread --> pkg_session pkg_workflow_worker_thread --> pkg_subagent pkg_workflow_worker_thread --> pkg_tools pkg_workflow_worker_thread --> pkg_workflow pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_invariants pkg_subagent_fork_in_process --> pkg_session pkg_subagent_fork_in_process --> pkg_subagent pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_api_remotes --> pkg_agent_presets @@ -1310,7 +1150,6 @@ flowchart TD pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session @@ -1321,21 +1160,17 @@ flowchart TD pkg_api_remotes --> pkg_user_questions 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_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_invariants pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools - pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session pkg_sdk_jsonrpc_server --> pkg_agent pkg_sdk_jsonrpc_server --> pkg_attachment - pkg_sdk_jsonrpc_server --> pkg_invariants pkg_sdk_jsonrpc_server --> pkg_llm pkg_sdk_jsonrpc_server --> pkg_llm_deepseek pkg_sdk_jsonrpc_server --> pkg_scope @@ -1343,7 +1178,6 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants pkg_subagent_dsh_sdk --> pkg_llm pkg_subagent_dsh_sdk --> pkg_sdk_client pkg_subagent_dsh_sdk --> pkg_session @@ -1351,49 +1185,41 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_subprocess pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection - 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_ui_renderer pkg_client_locale --> pkg_client_ui_settings - pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_locale 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_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_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_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_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 - 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 @@ -1407,7 +1233,6 @@ flowchart TD 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 @@ -1425,7 +1250,6 @@ flowchart TD 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_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller @@ -1435,7 +1259,6 @@ flowchart TD 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_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path @@ -1449,7 +1272,6 @@ flowchart TD 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 @@ -1457,42 +1279,35 @@ flowchart TD 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_api_remotes 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 @@ -1502,7 +1317,6 @@ flowchart TD 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 @@ -1512,7 +1326,6 @@ flowchart TD 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_llm pkg_client_ui_trajectory --> pkg_session pkg_client_ui_trajectory --> pkg_tools @@ -1522,7 +1335,6 @@ flowchart TD 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 @@ -1535,7 +1347,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_session pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team - pkg_experimental_client_ui_agent_team --> pkg_invariants pkg_experimental_client_ui_agent_team --> pkg_session pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_client_ui_chat --> pkg_agent @@ -1553,7 +1364,6 @@ flowchart TD 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 @@ -1570,7 +1380,6 @@ flowchart TD 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_api_session_controller @@ -1578,7 +1387,6 @@ flowchart TD pkg_client_ui_reference --> pkg_client_locale 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_client_ui_reference --> pkg_util_workspace_path @@ -1589,7 +1397,6 @@ flowchart TD 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 @@ -1598,7 +1405,6 @@ flowchart TD 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_attachment pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale @@ -1607,7 +1413,6 @@ flowchart TD 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_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence pkg_session_log_export --> pkg_session_query @@ -1616,14 +1421,12 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory - pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_api_remotes 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 @@ -1635,7 +1438,6 @@ flowchart TD 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 @@ -1645,7 +1447,6 @@ flowchart TD 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 @@ -1657,7 +1458,6 @@ flowchart TD 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 @@ -1668,7 +1468,6 @@ flowchart TD 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 @@ -1678,7 +1477,6 @@ flowchart TD 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_tool --> pkg_util_workspace_path pkg_client_ui_workflow_run --> pkg_api_session_controller pkg_client_ui_workflow_run --> pkg_client_locale @@ -1686,7 +1484,6 @@ flowchart TD 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 @@ -1701,7 +1498,6 @@ flowchart TD 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_test_runtime --> pkg_subagent pkg_client_ui_skill --> pkg_api_remotes @@ -1711,7 +1507,6 @@ flowchart TD 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 @@ -1722,255 +1517,254 @@ flowchart TD 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 | | --- | --- | --- | +| [`atomic-write`](../packages/util/atomic-write) | `util` | — | +| [`brand`](../packages/util/brand) | `util` | — | +| [`home-paths`](../packages/util/home-paths) | `util` | — | +| [`launch-environment`](../packages/util/launch-environment) | `util` | — | +| [`native-command`](../packages/util/native-command) | `util` | — | +| [`output-retention`](../packages/util/output-retention) | `util` | — | +| [`timeout`](../packages/util/timeout) | `util` | — | +| [`util-crypto`](../packages/util/crypto) | `util` | — | +| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | +| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | +| [`cmdline`](../packages/boot/cmdline) | `boot` | — | +| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | +| [`base`](../packages/bundle/base) | `bundle` | — | +| [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | +| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | +| [`client-store`](../packages/client/store) | `client` | — | +| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — | +| [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | +| [`client-web`](../packages/client/web) | `client` | — | +| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | — | +| [`e2b`](../packages/e2b/e2b) | `e2b` | — | +| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | +| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | +| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | +| [`host-directory-picker`](../packages/host/directory-picker) | `host` | — | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — | +| [`host-webserver`](../packages/host/webserver) | `host` | — | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | -| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`launch-environment`](../packages/util/launch-environment) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — | +| [`storage`](../packages/storage/storage) | `storage` | — | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | — | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | +| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | +| [`typert-generator`](../packages/typert/generator) | `typert` | — | +| [`typert-protocol`](../packages/typert/protocol) | `typert` | — | +| [`typert-registry`](../packages/typert/registry) | `typert` | — | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | -| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `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) | -| [`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) | -| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`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) | -| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | -| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | +| [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | +| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | -| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`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) | -| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`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), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`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) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | -| [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | -| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`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) | -| [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | +| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | +| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | +| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-stats`](../packages/session/session-stats) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`settings`](../packages/settings/settings) | +| [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`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), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`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) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`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), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | +| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | +| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`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) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | +| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | +| [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | -| [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | +| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | +| [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | +| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | -| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`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-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`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-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`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) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`typert-protocol`](../packages/typert/protocol) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`typert-registry`](../packages/typert/registry) | +| [`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), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`typert-protocol`](../packages/typert/protocol) | | [`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-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | +| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`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), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`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-settings-controller`](../packages/api/settings-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), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`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) | -| [`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) | -| [`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), [`attachment`](../packages/attachment/attachment), [`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) | -| [`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-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-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), [`util-workspace-path`](../packages/util/workspace-path), [`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-remotes`](../packages/api/remotes), [`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), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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` | [`api-remotes`](../packages/api/remotes), [`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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`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-settings`](../packages/client/ui-settings), [`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), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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` | [`attachment`](../packages/attachment/attachment), [`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-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), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`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), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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), [`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-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-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), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`subagent`](../packages/subagent/subagent) | -| [`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) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| [`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-settings-controller`](../packages/api/settings-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), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`session`](../packages/core/session) | +| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`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), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `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) | +| [`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) | +| [`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) | +| [`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), [`settings`](../packages/settings/settings) | +| [`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) | +| [`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) | +| [`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), [`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), [`util-workspace-path`](../packages/util/workspace-path), [`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) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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), [`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), [`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) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace) | +| [`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) | +| [`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), [`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) | +| [`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), [`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), [`settings`](../packages/settings/settings) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`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-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`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), [`session`](../packages/core/session) | +| [`client-ui-reference`](../packages/client/ui-reference) | `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), [`file-reference`](../packages/context/file-reference), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`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), [`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) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`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-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`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), [`client-ui-trajectory`](../packages/client/ui-trajectory) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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), [`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), [`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), [`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-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), [`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-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), [`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), [`util-workspace-path`](../packages/util/workspace-path) | +| [`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), [`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), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`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), [`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) | diff --git a/packages/AGENTS.md b/packages/AGENTS.md index af47b16740..1674edaf1b 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,7 +15,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. -- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). +- **Publish `./invariant` only for diverging observations.** Check an owned relation under the manifest name. Otherwise omit wiring and give the package-specific README reason. Empty companions and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md). [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 304b9e8d6d..9ae3c6e0a9 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/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/acp/acp/README.md -README.md: 5ba5b740dd35a99b57c82c4ee18753781742b7a7 -README.zh.md: 80391c879109fdc44e9b396ee4ca0016316dbad7 +README.md: 3a436ca341265403ea380268a8aa3213e7624d17 +README.zh.md: 46d6ce4d17ad04cb4d963f0f119e9f09d3c7a8d0 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 5ba5b740dd..3a436ca341 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -102,7 +102,7 @@ The decision history lives in the [ACP as an automation-only protocol note](../. | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `AgentSideConnection` wiring, per-session records, admission and settlement, teardown | | [`src/content.ts`](src/content.ts) | Wire-content admission and projection: image validation, route recheck, prompt reconstruction, assistant block conversion | | [`src/codec.ts`](src/codec.ts) | Pure turn-ending to ACP `stopReason` mapping | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; this transport owns no durable package-local event stream) | +| — | No runtime invariant companion is published; this transport owns no durable package-local event stream; protocol and lifecycle tests cover its mapping. | ### Admission and prompt settlement diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 80391c8791..46d6ce4d17 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -102,7 +102,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`AgentSideConnection` 接线、按会话记录、准入与结算、清理 | | [`src/content.ts`](src/content.ts) | 协议内容准入与投影:图片校验、路由重查、提示词重建、assistant 块转换 | | [`src/codec.ts`](src/codec.ts) | 轮次结束到 ACP `stopReason` 的纯映射 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;本传输不拥有持久包内事件流) | +| — | 不发布运行时不变式伴生入口;本传输不拥有持久包内事件流。 | ### 准入与提示词结算 diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 0e56f2a481..296f775b29 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -18,16 +18,11 @@ "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", @@ -38,7 +33,6 @@ "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -57,7 +51,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/acp/acp/src/invariant.ts b/packages/acp/acp/src/invariant.ts deleted file mode 100644 index d4db1c964c..0000000000 --- a/packages/acp/acp/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-acp`. - * @module @deepseek-ai/dsh-acp/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-acp' - -/** Cordis companion plugin name. */ -export const name = 'acp-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this transport owns no durable package-local event stream; - * protocol and lifecycle tests cover its mapping. - */ -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/acp/acp/tsconfig.json b/packages/acp/acp/tsconfig.json index 71276d8ee3..ea1239b9f1 100644 --- a/packages/acp/acp/tsconfig.json +++ b/packages/acp/acp/tsconfig.json @@ -40,9 +40,6 @@ }, { "path": "../../interaction/user-approval" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index f1d957d143..714e5602c2 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: d10eed46d5534a2459995bd687942d799f009c7b -README.zh.md: 310b44cfe633758089771cda4040679c09373ec2 +README.md: 68e6e0f104b9accc1fecf125a24fb9c18cce966e +README.zh.md: 9a363fd2c3861161c7c032af5b152208d061ea91 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index d10eed46d5..68e6e0f104 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -80,3 +80,5 @@ No direct effect; invoked business Services own any model-visible result. None. + +**Runtime invariant:** No companion is published. Host calls re-read authoritative Cordis and Typert state, while Client methods, descriptors, and `$on` subscriptions mutate in one owned effect. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 310b44cfe6..9a363fd2c3 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -80,3 +80,5 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source 无。 + +**运行时不变式:** 不发布伴生入口。Host 调用会重新读取权威的 Cordis 与 Typert 状态,Client 方法、描述与 `$on` 订阅则在同一个 effect 中完成变更。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d5601ab188..79856e61f8 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -18,10 +18,6 @@ "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" @@ -49,7 +45,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" @@ -65,7 +60,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -73,7 +67,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@types/ws": "^8.18.1", diff --git a/packages/api/gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts deleted file mode 100644 index 365d741005..0000000000 --- a/packages/api/gateway/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`. - * @module @deepseek-ai/dsh-api-gateway/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-api-gateway' - -/** Cordis companion plugin name. */ -export const name = 'api-gateway-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: Host calls re-read authoritative Cordis and Typert - * state, while Client methods, descriptors, and `$on` subscriptions mutate in - * one owned effect. - */ -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/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 54d5f964f3..a3b4afd962 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -7,7 +7,6 @@ }, "files": [ "src/index.ts", - "src/invariant.ts", "src/stream-protocol.ts", "src/stream-server.ts", "src/types.ts" @@ -22,9 +21,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../client/connection/tsconfig.host.json" }, diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts index f9049b6067..2f199f0d1f 100644 --- a/packages/api/gateway/tsdown.config.ts +++ b/packages/api/gateway/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js']) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 43991614b9..475231a7c3 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/remotes/README.md -README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 -README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 +README.md: 77d30c6234d08b0f3a1f60b6355fb21807210144 +README.zh.md: c7ca240eccfa9de88116bc76fef3b86bb2e77f86 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f855bd1cd..77d30c6234 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -80,3 +80,5 @@ No direct effect; mounted Host capabilities own any model-visible behavior they None. + +**Runtime invariant:** No companion is published. Typert and the Agent/Session registries own the observed relationships. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index edad8d735d..c7ca240ecc 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -80,3 +80,5 @@ Host entry 为每条 Client stream 独立注册 allowlist listener 和队列, 无。 + +**运行时不变式:** 不发布伴生入口。被观察的关系由 Typert 以及 Agent、Session 注册表负责。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a271fe29a..d814be7ba4 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -18,10 +18,6 @@ "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" @@ -49,7 +45,6 @@ "license": "MIT", "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" @@ -71,7 +66,6 @@ "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -94,7 +88,6 @@ "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts deleted file mode 100644 index bb0d706eda..0000000000 --- a/packages/api/remotes/src/invariant.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' - -/** Cordis companion plugin name. */ -export const name = 'api-remotes-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: Typert and the Agent/Session registries own the observed relationships. */ -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/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 4dd513bdeb..537d028fb5 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -7,7 +7,6 @@ }, "files": [ "src/index.ts", - "src/invariant.ts", "src/remote-events.ts", "src/types.ts" ], @@ -48,9 +47,6 @@ { "path": "../../interaction/user-questions" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../session-controller/tsconfig.host.json" }, diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 3c72df8718..29349148d6 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -2,6 +2,6 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( '@deepseek-ai/dsh-api-remotes', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index fc66cdfceb..d14a18689b 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/session-controller/README.md -README.md: 689635b83bce71e3aecfedf371685a24bede55ab -README.zh.md: 68da7db60b38aca1b72c991005438ced45b348ea +README.md: 362f02f44ea65dde1470133f67f39d19f62129db +README.zh.md: 40bbe8b3c0a325107aabc2efdef682ce67b3a276 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 689635b83b..362f02f44e 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -72,3 +72,5 @@ No direct effect; model requests remain owned by the Agent and LLM packages. None. + +**Runtime invariant:** No companion is published. Every page and frame is checked against the addressed durable Session. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 68da7db60b..40bbe8b3c0 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -72,3 +72,5 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 无。 + +**运行时不变式:** 不发布伴生入口。每个分页与帧都会对照其指向的持久 Session 校验。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 0cdd53a2a3..079e89ba3b 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -62,7 +58,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -86,7 +81,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -120,7 +114,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", diff --git a/packages/api/session-controller/src/invariant.ts b/packages/api/session-controller/src/invariant.ts deleted file mode 100644 index d225d978ff..0000000000 --- a/packages/api/session-controller/src/invariant.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-session-controller/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-api-session-controller' - -/** Cordis companion plugin name. */ -export const name = 'api-session-controller-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: every page and frame is checked against the addressed durable Session. */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index bea21672b7..913da26518 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -7,7 +7,6 @@ }, "files": [ "src/index.ts", - "src/invariant.ts", "src/types.ts", "src/remote-events.ts", "src/agent.ts", @@ -34,7 +33,6 @@ { "path": "../../llm/llm" }, { "path": "../../util/native-command" }, { "path": "../../preset/agent-presets" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../session/session-persistence" }, { "path": "../../session/session-projection" }, { "path": "../../session/session-projection-cache" }, diff --git a/packages/api/session-controller/tsdown.config.ts b/packages/api/session-controller/tsdown.config.ts index 9ac9ebf59b..bc2abfa538 100644 --- a/packages/api/session-controller/tsdown.config.ts +++ b/packages/api/session-controller/tsdown.config.ts @@ -2,6 +2,6 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( '@deepseek-ai/dsh-api-session-controller', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index cc5d27a374..045d7b8753 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/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/settings-controller/README.md -README.md: 5032b8ff352d35abc05384719932691a93a49f84 -README.zh.md: 756a7fcdd1cd03b157ff5781aadd4db092c6d374 +README.md: 761e4c43751675eec141a2efcf85caf52cc91590 +README.zh.md: 541c9deb3dbc35914e4e95abdda2af5cdce2bbd0 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index 5032b8ff35..761e4c4375 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -68,3 +68,5 @@ No direct effect; reading or writing these configuration values does not alter m None. + +**Runtime invariant:** No companion is published. The settings and credential seams own storage and update events, while this package only projects their methods onto the wire. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 756a7fcdd1..541c9deb3d 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -68,3 +68,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。settings 与 credential seam 负责存储和更新事件,本包只把它们的方法投影到 wire。 diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 3f3039aa68..881307111d 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -39,7 +35,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/typert.host.js", @@ -56,7 +51,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", @@ -66,7 +60,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/api/settings-controller/src/invariant.ts b/packages/api/settings-controller/src/invariant.ts deleted file mode 100644 index a4da3cb426..0000000000 --- a/packages/api/settings-controller/src/invariant.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-settings-controller/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-api-settings-controller' - -/** Cordis companion plugin name. */ -export const name = 'api-settings-controller-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the settings and credential seams own storage and - * update events, while this package only projects their methods onto the wire. - */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/api/settings-controller/tests/invariant.spec.ts b/packages/api/settings-controller/tests/invariant.spec.ts deleted file mode 100644 index 6454249191..0000000000 --- a/packages/api/settings-controller/tests/invariant.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as SettingsControllerInvariant from '../src/invariant.ts' - -describe('api-settings-controller invariant companion', () => { - it('reserves the package name against duplicate registration', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry) - await ctx.plugin(SettingsControllerInvariant) - - expect(() => { - ctx.invariants.register('@deepseek-ai/dsh-api-settings-controller', () => {}) - }).toThrow(/already registered/) - }) -}) diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json index 763aa37777..0ea09072a4 100644 --- a/packages/api/settings-controller/tsconfig.json +++ b/packages/api/settings-controller/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../core/session" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/native-command" }, diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index 3f837582cb..50550c156a 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/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/workspace-controller/README.md -README.md: 731a6331e2a19991921c022759f6bbd971cef525 -README.zh.md: f46c78b7b300f26983eee94d3dfdb488d236116e +README.md: 9cbd7fcd3dc383fac5b3aa3af9af7d59e1629a51 +README.zh.md: 42f41da3de8805f80f7c762020a2d7b33fbf361a diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 731a6331e2..9cbd7fcd3d 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -54,3 +54,5 @@ No direct effect; Workspace mutations do not alter model requests. None. + +**Runtime invariant:** No companion is published. Workspace Registry owns persistence; every stream generation is a full projection. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index f46c78b7b3..42f41da3de 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -54,3 +54,5 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。 无。 + +**运行时不变式:** 不发布伴生入口。Workspace Registry 负责持久化,每次流生成都是完整投影。 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 14586d912e..e90a7241d1 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -59,7 +55,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", @@ -77,7 +72,6 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -89,7 +83,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/api/workspace-controller/src/invariant.ts b/packages/api/workspace-controller/src/invariant.ts deleted file mode 100644 index 1e2835db0e..0000000000 --- a/packages/api/workspace-controller/src/invariant.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-workspace-controller/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-api-workspace-controller' - -/** Cordis companion plugin name. */ -export const name = 'api-workspace-controller-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: Workspace Registry owns persistence; every stream generation is a full projection. */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 4b98892781..5dbdb71643 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -7,7 +7,6 @@ }, "files": [ "src/index.ts", - "src/invariant.ts", "src/types.ts", "src/commands.ts", "src/directory-picker.ts", @@ -17,7 +16,6 @@ { "path": "../../../vendor/cordis" }, { "path": "../../core/session" }, { "path": "../../host/directory-picker" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, { "path": "../../workspace/workspace" } diff --git a/packages/api/workspace-controller/tsdown.config.ts b/packages/api/workspace-controller/tsdown.config.ts index 7bc3021981..a79662aebc 100644 --- a/packages/api/workspace-controller/tsdown.config.ts +++ b/packages/api/workspace-controller/tsdown.config.ts @@ -2,6 +2,6 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( '@deepseek-ai/dsh-api-workspace-controller', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index f3250efc09..a019d28997 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: 2b4c4e17ee8578d63c3f2ef44f7b2c44f116906a -README.zh.md: f44b0cf975b2a86c8e5b7c8ba585701fe88c19f2 +README.md: 5e9bda9aa13939c3945d005ea474c3caf8daa661 +README.zh.md: 88dcbff120c4c61ea776e43fc906923f039ee09b diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 2b4c4e17ee..5e9bda9aa1 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -96,7 +96,7 @@ Request versions live below `/attachments/v1/request-images/`. `readIm | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates | | [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight | | [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; immutable writes and verified reads enforced at the backend boundary) | +| — | No runtime invariant companion is published; immutable writes and verified reads are enforced directly at the backend boundary. | diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index f44b0cf975..88dcbff120 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -96,7 +96,7 @@ kind: "package-reference" | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 | | [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight | | [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;不可变写入与校验读取在后端边界直接强制) | +| — | 不发布运行时不变式伴生入口;不可变写入与校验读取在后端边界直接强制。 | diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 550a44efd6..44daf04927 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -15,15 +15,13 @@ "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"], + "files": ["lib/index.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -33,7 +31,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/attachment/attachment-local/src/invariant.ts b/packages/attachment/attachment-local/src/invariant.ts deleted file mode 100644 index 2e37667801..0000000000 --- a/packages/attachment/attachment-local/src/invariant.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/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-attachment-local' -/** Cordis companion plugin name. */ -export const name = 'attachment-local-invariant' -/** Services required before package ownership can be reserved. */ -export const inject = ['invariants', 'attachments'] -/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */ -const install: InvariantInstaller = () => {} -/** - * Register the package invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the registration disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json index 3ac8b3fcff..8466ea1ac4 100644 --- a/packages/attachment/attachment-local/tsconfig.json +++ b/packages/attachment/attachment-local/tsconfig.json @@ -5,8 +5,8 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../attachment" }, - { "path": "../../util/home-paths" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../../util/home-paths" } ] } diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index e67d95604d..f0d71026da 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: 01a5d6ee143ff53175dd7327cd6d419af61938a3 -README.zh.md: 1a1d297297a6815ce5338391af0182e471f99000 +README.md: 785a2df6f178de94e67ce681a3948a3ec6fcfa29 +README.zh.md: 60efa22efc7edee7ff58b6d74f27d18655f4aa1f diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 01a5d6ee14..785a2df6f1 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -78,7 +78,7 @@ The service family runs one admission-and-storage flow: every entry point enforc | [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`: canonical-base64 enforcement, then `saveImages` delegation | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; implementations enforce immutable-store checks) | +| — | No runtime invariant companion is published; this stateless seam owns types while implementations enforce immutable-store checks. | diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 1a1d297297..60efa22efc 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -78,7 +78,7 @@ kind: "package-reference" | [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`:规范 base64 强制,随后委托 `saveImages` | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;实现负责强制不可变存储检查) | +| — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 | diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index d2448330bc..3b3d17ef0a 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -15,21 +15,18 @@ "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" }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/attachment/attachment/src/invariant.ts b/packages/attachment/attachment/src/invariant.ts deleted file mode 100644 index a44607b093..0000000000 --- a/packages/attachment/attachment/src/invariant.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/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-attachment' -/** Cordis companion plugin name. */ -export const name = 'attachment-invariant' -/** Service required before package ownership can be reserved. */ -export const inject = ['invariants'] -/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */ -const install: InvariantInstaller = () => {} -/** - * Register the package invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the registration disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json index 7a6b8d6ece..33e1dd58de 100644 --- a/packages/attachment/attachment/tsconfig.json +++ b/packages/attachment/attachment/tsconfig.json @@ -5,7 +5,6 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../../util/brand" } ] } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index b980e35c55..ef881a96ec 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 9094950a7b154d0feb0d8bd0b76e1f06ff2a10fb -README.zh.md: 3a3ef6c82182d23a50dfa460591b20e9ebfd291e +README.md: a9ed535c2662237229e0702dcf41eae4f93af5df +README.zh.md: 4f7688f7db5060bb1bf00ca5d091ca4ad16466ea diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 9094950a7b..a9ed535c26 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -96,7 +96,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla |---|---| | [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section | | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; boundary and replay tests cover the protocol mapping) | +| — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. | diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 3a3ef6c821..4f7688f7db 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -96,7 +96,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head |---|---| | [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 | | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;边界与回放测试覆盖其协议映射) | +| — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 | diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index bd03b913cd..451a97b1e7 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -18,16 +18,11 @@ "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", @@ -42,7 +37,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -59,7 +53,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/packages/boot/app-boot/src/invariant.ts b/packages/boot/app-boot/src/invariant.ts deleted file mode 100644 index 8195ecb553..0000000000 --- a/packages/boot/app-boot/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-app-boot`. - * @module @deepseek-ai/dsh-app-boot/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-app-boot' - -/** Cordis companion plugin name. */ -export const name = 'app-boot-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this presentation adapter owns no durable package-local event stream; - * boundary and replay tests cover its protocol mapping. - */ -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/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index c866abbc25..bb1ce09d7b 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../../vendor/hmr" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/system-prompt" }, diff --git a/packages/boot/app-boot/tsdown.config.ts b/packages/boot/app-boot/tsdown.config.ts index 6693770892..12ce019be6 100644 --- a/packages/boot/app-boot/tsdown.config.ts +++ b/packages/boot/app-boot/tsdown.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsdown' * app host bind to one Loader peer. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 2d4371b1a9..103acf25f1 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/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/boot/cmdline/README.md -README.md: 7ea825d8ef309cc295e3334bf5ae220e3e0524e5 -README.zh.md: 9f5f876ec6d1b408e2156dc3075499885032a141 +README.md: da23d16fac10790d549ab839adce2f3578bf5400 +README.zh.md: ed5ec93b20d5bd357b1cf19618fa36aaa555c41b diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 7ea825d8ef..da23d16fac 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -97,7 +97,7 @@ The parse path is one small family with two owners: `provideCmdline` freezes the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `CmdlineArgs`/`AppExit` types, `provideCmdline`, `parseCmdline`, commander exit/output routing | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; Loader settlement reports missing services) | +| — | No runtime invariant companion is published; `cmdlineArgs` is an immutable launcher fact that any number of ordinary plugins may read. App-owned providers and consumers use normal Cordis service injection, whose missing dependencies are already reported by Loader settlement. | diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 9f5f876ec6..ed5ec93b20 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -97,7 +97,7 @@ kind: "package-library" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `CmdlineArgs`/`AppExit` 类型、`provideCmdline`、`parseCmdline`、commander 退出/输出路由 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;Loader 结算会报告缺失的服务) | +| — | 不发布运行时不变式伴生入口;Loader 结算会报告缺失的服务。 | diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 83c272e666..10154271be 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -18,29 +18,22 @@ "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/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "commander": "^15.0.0", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts deleted file mode 100644 index b18094a1f4..0000000000 --- a/packages/boot/cmdline/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`. - * @module @deepseek-ai/dsh-cmdline/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' - -/** Cordis companion plugin name. */ -export const name = 'cmdline-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any - * number of ordinary plugins may read. App-owned providers and consumers use - * normal Cordis service injection, whose missing dependencies are already - * reported by Loader settlement. - */ -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)) diff --git a/packages/boot/cmdline/tsconfig.json b/packages/boot/cmdline/tsconfig.json index 79afae0743..f82b793fe4 100644 --- a/packages/boot/cmdline/tsconfig.json +++ b/packages/boot/cmdline/tsconfig.json @@ -16,9 +16,6 @@ }, { "path": "../../../vendor/loader" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/bundle/acp-app/README.i18n.yaml b/packages/bundle/acp-app/README.i18n.yaml index 3f7399f380..bf6b036708 100644 --- a/packages/bundle/acp-app/README.i18n.yaml +++ b/packages/bundle/acp-app/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/bundle/acp-app/README.md -README.md: 90846589d3ffa4eae71bc892251b07b69d96ce87 -README.zh.md: 0669e080e05c62f877e970e54d9f0b8ea946c4cc +README.md: d27beb0882b1ea1da894ff89ebaa62011df548bc +README.zh.md: ac60cd408db2df746d989f27433841d1eaa13386 diff --git a/packages/bundle/acp-app/README.md b/packages/bundle/acp-app/README.md index 90846589d3..d27beb0882 100644 --- a/packages/bundle/acp-app/README.md +++ b/packages/bundle/acp-app/README.md @@ -72,3 +72,5 @@ Stable for a fixed profile, provider, model, and tool roster. Profile changes ta None. + +**Runtime invariant:** No companion is published. The bundle adds a process transport and startup latch; source/built stdio tests own frame purity, help exclusion, and shutdown. diff --git a/packages/bundle/acp-app/README.zh.md b/packages/bundle/acp-app/README.zh.md index 0669e080e0..ac60cd408d 100644 --- a/packages/bundle/acp-app/README.zh.md +++ b/packages/bundle/acp-app/README.zh.md @@ -72,3 +72,5 @@ ACP v1 SDK 客户端先初始化 `dsh --profile acp`,再用绝对 `cwd` 与可 无。 + +**运行时不变式:** 不发布伴生入口。该 bundle 只增加进程传输与启动 latch;帧纯度、help 排除和关闭行为由源码及构建产物的 stdio 测试负责。 diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 4262b34412..3b036dfa92 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -18,17 +18,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -44,12 +39,10 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/acp-app/src/invariant.ts b/packages/bundle/acp-app/src/invariant.ts deleted file mode 100644 index 96099709a9..0000000000 --- a/packages/bundle/acp-app/src/invariant.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-acp-app`. - * @module @deepseek-ai/dsh-acp-app/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-acp-app' - -/** Cordis companion plugin name. */ -export const name = 'acp-app-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the bundle adds a process transport and startup latch; - * source/built stdio tests own frame purity, help exclusion, and shutdown. - */ -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)) diff --git a/packages/bundle/acp-app/tsconfig.json b/packages/bundle/acp-app/tsconfig.json index 1d644141bd..5ef7bc5662 100644 --- a/packages/bundle/acp-app/tsconfig.json +++ b/packages/bundle/acp-app/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../boot/cmdline" } diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 21455662b4..590321b73d 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 4fc828a5614245bf89867e2c52d1b2019e300921 -README.zh.md: 0d80938229624dd98536bf4798e1541fd84bd8e1 +README.md: 6c02fcf946368c1ea931bd8114c1e4cc828af5ea +README.zh.md: f69853902fa48db4349cc52e2d06a4239a281870 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 4fc828a561..6c02fcf946 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -81,12 +81,12 @@ The patch gates the two shell stacks by platform on its own rows: `bash-sandbox` |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | The bundle substance: the base plugin rows, with per-row rationale as inline comments | | [`src/index.ts`](src/index.ts) | Package entry; carries no runtime API | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no runtime invariant; each inserted row's package owns its invariants | +| — | No runtime invariant companion is published; the package is a static patch-list carrier (a YAML document of loader rows owned by other packages); it mounts no service, emits no events, and owns no mutable relation to check. Each inserted row's own package carries that row's invariants. | | [`tests/base.spec.ts`](tests/base.spec.ts) | Manifest declaration and platform-gating checks | ### Invariant ownership -The invariant companion registers an empty installer because the package is a static patch-list carrier: each inserted row's own package carries that row's invariants, and the bundle owns no mutable relation to check. +No invariant companion is published because the package is a static patch-list carrier: each inserted row's package owns that row's invariants, and the bundle owns no mutable relation to check. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 0d80938229..f69853902f 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -81,12 +81,12 @@ patch 在自身上按平台门控两个 shell 栈:`bash-sandbox` 与 `tool-bas |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | 组合包的实体:基础插件行,附以行内注释说明各行依据 | | [`src/index.ts`](src/index.ts) | 包入口;不携带任何运行时 API | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:无运行时不变式;每条插入行所属的包负责自己的不变式 | +| — | 不发布运行时不变式伴生入口;本包只持有静态 patch 列表,插入的各行分别负责自己的不变式。 | | [`tests/base.spec.ts`](tests/base.spec.ts) | manifest 声明与平台门控检查 | ### 不变式归属 -不变式伴生插件注册一个空安装器,因为本包是静态 patch 列表载体:每条插入行由所属的包携带其不变式,组合包自身没有任何可审计的可变关系。 +不发布不变式伴生入口,因为本包是静态 patch 列表载体:每条插入行由所属的包负责其不变式,组合包自身没有任何可审计的可变关系。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index daef0bab99..b4a0470b6b 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -18,17 +18,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -126,11 +121,9 @@ "@deepseek-ai/dsh-agent-instructions": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/base/src/invariant.ts b/packages/bundle/base/src/invariant.ts deleted file mode 100644 index a3f9b51de0..0000000000 --- a/packages/bundle/base/src/invariant.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-base`. - * @module @deepseek-ai/dsh-base/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-base' - -/** Cordis companion plugin name. */ -export const name = 'base-bundle-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -// No runtime invariant: the package is a static patch-list carrier (a YAML -// document of loader rows owned by other packages); it mounts no service, -// emits no events, and owns no mutable relation to check. Each inserted row's -// own package carries that row's invariants. -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)) diff --git a/packages/bundle/base/tsconfig.json b/packages/bundle/base/tsconfig.json index 8f58ed6e28..f1a449634c 100644 --- a/packages/bundle/base/tsconfig.json +++ b/packages/bundle/base/tsconfig.json @@ -10,9 +10,6 @@ "references": [ { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 0aba9ff549..92ca7fd300 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/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/bundle/headless/README.md -README.md: 282fa76dd720751d61ba7f44e5e35884d5f21e8f -README.zh.md: 5a2727a25e6e4840900ebc9dc70d10ff13db12dd +README.md: 644a96ebb19c9ccecbfb3a08fdf4182dc668f0e5 +README.zh.md: e1b954f876b7f90167e4dab313b88f66b1d20512 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 282fa76dd7..644a96ebb1 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -78,13 +78,13 @@ A completed final `turn/end` exits 0; any other outcome — aborted, error, or n | [`src/index.ts`](src/index.ts) | The `headless-runner` plugin: run flow, output contract, exit mapping | | [`src/startup.ts`](src/startup.ts) | The `headless-startup` provider: task positional and `--help` | | [`cordis.patch.yml`](cordis.patch.yml) | The one-shot patch over `dsh-base` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no runtime invariant; the observable contract is process-level | +| — | No runtime invariant companion is published; the runner's observable contract (provider reasoning on stderr, final text on stdout, exit code by turn-end reason) is process-level and owned by the launcher e2e; it registers nothing and holds no mutable relation to audit inside the tree. | | [`tests/headless.spec.ts`](tests/headless.spec.ts) | Run flow, aggregation, flush, and exit mapping | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | ### Invariant ownership -The invariant companion registers an empty installer because the runner's observable contract (final text on stdout, exit code by turn-end reason) is process-level and owned by the launcher e2e; the plugin registers nothing and holds no mutable relation to audit inside the tree. +No invariant companion is published because the runner's observable contract (final text on stdout, exit code by turn-end reason) is process-level and owned by the launcher e2e; the plugin registers nothing and holds no mutable relation to audit inside the tree. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 5a2727a25e..e1b954f876 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -78,13 +78,13 @@ patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-promp | [`src/index.ts`](src/index.ts) | `headless-runner` 插件:运行流程、输出约定、退出映射 | | [`src/startup.ts`](src/startup.ts) | `headless-startup` 提供方:任务位置参数与 `--help` | | [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的一次性 patch | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:无运行时不变式;可观察约定是进程级的 | +| — | 不发布运行时不变式伴生入口;可观察的行为属于进程级组合,本包只持有静态 patch 列表。 | | [`tests/headless.spec.ts`](tests/headless.spec.ts) | 运行流程、汇总、flush 与退出映射 | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | ### 不变式归属 -不变式伴生插件注册一个空安装器,因为 runner 的可观察约定(stdout 的最终文本、按轮次结束原因决定的退出码)是进程级的、由启动器 e2e 负责;插件不注册任何内容,树内也没有任何可变关系可审计。 +不发布不变式伴生入口,因为 runner 的可观察约定(stdout 的最终文本、按轮次结束原因决定的退出码)是进程级的、由启动器 e2e 负责;插件不注册任何内容,树内也没有任何可变关系可审计。 diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 474dc74dd6..c610caf2bd 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -22,17 +22,12 @@ "types": "./lib/types/startup.d.ts", "default": "./lib/startup.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" @@ -53,7 +48,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -62,7 +56,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/bundle/headless/src/invariant.ts b/packages/bundle/headless/src/invariant.ts deleted file mode 100644 index 0d22891eb2..0000000000 --- a/packages/bundle/headless/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-headless`. - * @module @deepseek-ai/dsh-headless/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-headless' - -/** Cordis companion plugin name. */ -export const name = 'headless-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the runner's observable contract (provider reasoning - * on stderr, final text on stdout, exit code by turn-end reason) is - * process-level and owned by the launcher e2e; it registers nothing and holds - * no mutable relation to audit inside the tree. - */ -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)) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 736dec83b0..0c60d69aa7 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../core/session" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../boot/cmdline" } diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index 83a58e47bb..cd2ccefc48 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/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/bundle/sdk-app/README.md -README.md: 36b1964d6a22a3fa1ddc384cdf5f973b51fc1ca4 -README.zh.md: b4871e937ec1f6a7e8b03ac09a0bd03bbb0f55ca +README.md: c83ff2178ebc3dd059c926270564306af0828b72 +README.zh.md: 9b25cbfde70f23309b2dc78a237043d0e8101528 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 36b1964d6a..c83ff2178e 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -68,3 +68,5 @@ Stable for a fixed profile, provider, model, and tool roster. Profile changes ta None. + +**Runtime invariant:** No companion is published. The bundle adds a process transport and startup latch; source/built stdio tests own frame purity, help exclusion, and shutdown. diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index b4871e937e..9b25cbfde7 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -68,3 +68,5 @@ profile 会在 base 工具与上下文贡献之前提供 `You are a coding agent 无。 + +**运行时不变式:** 不发布伴生入口。该 bundle 只增加进程传输与启动 latch;帧纯度、help 排除和关闭行为由源码及构建产物的 stdio 测试负责。 diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 5e02cda63e..b01b60d39a 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -18,17 +18,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -45,12 +40,10 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/sdk-app/src/invariant.ts b/packages/bundle/sdk-app/src/invariant.ts deleted file mode 100644 index c3e6c41d63..0000000000 --- a/packages/bundle/sdk-app/src/invariant.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-app`. - * @module @deepseek-ai/dsh-sdk-app/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-app' - -/** Cordis companion plugin name. */ -export const name = 'sdk-app-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the bundle adds a process transport and startup latch; - * source/built stdio tests own frame purity, help exclusion, and shutdown. - */ -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)) diff --git a/packages/bundle/sdk-app/tsconfig.json b/packages/bundle/sdk-app/tsconfig.json index 0a98d3117c..cd8a7f59e9 100644 --- a/packages/bundle/sdk-app/tsconfig.json +++ b/packages/bundle/sdk-app/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../boot/cmdline" } diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 8230113dff..faf88853e9 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/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/bundle/sdk-minimal/README.md -README.md: cf9333cd4304f24344b79d54b4149dadf246c325 -README.zh.md: eeda31bff9e34fd20dae0e43c5678c0e398e0649 +README.md: af6df054a8a7535f728aa92f52b90f503163d0ec +README.zh.md: 683e34187ead5fafabd64fff74f2ca07fdad9bea diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index cf9333cd43..af6df054a8 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -54,7 +54,7 @@ The bundle's single insert is the complete application tree: SDK stdio startup a |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | Complete standalone profile tree and its environment-backed defaults | | [`src/index.ts`](src/index.ts) | Bundle package entry | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the static composition | +| — | No runtime invariant companion is published; the package is a static patch-list carrier whose inserted rows own their runtime relationships and invariant companions. | | [`tests/sdk-minimal.spec.ts`](tests/sdk-minimal.spec.ts) | Exact composition, profile-name, and platform-selection checks | diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index eeda31bff9..683e34187e 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -54,7 +54,7 @@ dsh --profile sdk-minimal |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | 完整独立 profile 配置树及其环境默认值 | | [`src/index.ts`](src/index.ts) | Bundle 包入口 | -| [`src/invariant.ts`](src/invariant.ts) | 静态组合的不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包只是静态 patch 列表载体,插入的各行分别拥有自己的运行时关系和不变式。 | | [`tests/sdk-minimal.spec.ts`](tests/sdk-minimal.spec.ts) | 精确组合、profile 名称与平台选择检查 | diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index a0048190db..7049744a0d 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -18,17 +18,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -59,11 +54,9 @@ "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/sdk-minimal/src/invariant.ts b/packages/bundle/sdk-minimal/src/invariant.ts deleted file mode 100644 index e2f480504a..0000000000 --- a/packages/bundle/sdk-minimal/src/invariant.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-minimal`. - * @module @deepseek-ai/dsh-sdk-minimal/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-minimal' - -/** Cordis companion plugin name. */ -export const name = 'sdk-minimal-bundle-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -// No runtime invariant: the package is a static patch-list carrier whose -// inserted rows own their runtime relationships and invariant companions. -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)) diff --git a/packages/bundle/sdk-minimal/tsconfig.json b/packages/bundle/sdk-minimal/tsconfig.json index 8f58ed6e28..f1a449634c 100644 --- a/packages/bundle/sdk-minimal/tsconfig.json +++ b/packages/bundle/sdk-minimal/tsconfig.json @@ -10,9 +10,6 @@ "references": [ { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 4f438e7d45..adb8e79e9f 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: 5588c7e72e6d61454620075c239ea571d026c5cf -README.zh.md: bfd7a58bf5343ddd06b49b97b622493f389da72f +README.md: c352ec937ecfa51f36eae1970067a62aed51b643 +README.zh.md: cc3b5e5660cd7fdb38f6d9084669491b1df27ee7 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 5588c7e72e..c352ec937e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -90,7 +90,7 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon | [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff | | [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--trusted-host`, `--no-open`, `--help` | | [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, agent plane behind presets | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no runtime invariant; every contribution is registry-disposed | +| — | No runtime invariant companion is published; every contribution (frontend-static child plugin, prompt section, bashEnv registration) is registry-disposed with the fiber, and each owning registry's package carries that relation's invariant; the package holds no mutable state of its own to audit. | | [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | | [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN-trust sampling | @@ -98,7 +98,7 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon ### Invariant ownership -The invariant companion registers an empty installer because every contribution — the frontend-static child plugin, the prompt sections, and the bash variable registration — is registry-disposed with the fiber, and each owning registry's package carries that relation's invariant. +No invariant companion is published because every contribution — the frontend-static child plugin, the prompt sections, and the bash variable registration — is registry-disposed with the fiber, and each owning registry package carries that relation's invariant. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index bfd7a58bf5..cc3b5e5660 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -90,7 +90,7 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 | [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 | | [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--trusted-host`、`--no-open`、`--help` | | [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 之后的 agent 层 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:无运行时不变式;每项贡献都由 registry 释放 | +| — | 不发布运行时不变式伴生入口;本包只持有静态 contribution 列表,每项 contribution 都由其 registry 释放。 | | [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、fallback 席位、提示词段落、就绪宣告 | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | | [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN 信任采样 | @@ -98,7 +98,7 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 ### 不变式归属 -不变式伴生插件注册一个空安装器,因为每项贡献——frontend-static 子插件、提示词段落与 bash 变量注册——都会随 fiber 由 registry 释放,且每个所属 registry 的包负责该关系的不变式。 +不发布不变式伴生入口,因为每项贡献——frontend-static 子插件、提示词段落与 bash 变量注册——都会随 fiber 由 registry 释放,且每个所属 registry 的包负责该关系的不变式。 diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index a018658347..5ee354b85a 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -22,17 +22,12 @@ "types": "./lib/types/startup.d.ts", "default": "./lib/startup.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" @@ -118,14 +113,12 @@ "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/bundle/web-app/src/invariant.ts b/packages/bundle/web-app/src/invariant.ts deleted file mode 100644 index 13df7f956c..0000000000 --- a/packages/bundle/web-app/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-web-app`. - * @module @deepseek-ai/dsh-web-app/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-web-app' - -/** Cordis companion plugin name. */ -export const name = 'web-app-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: every contribution (frontend-static child plugin, - * prompt section, bashEnv registration) is registry-disposed with the fiber, - * and each owning registry's package carries that relation's invariant; the - * package holds no mutable state of its own to audit. - */ -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)) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 2f8cc6c3b2..d4a74c09ba 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -43,9 +43,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 29a111effe..b07cd75640 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -135,7 +135,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, optional `./invariant` only for an independent runtime relationship, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js'])`, plus `lib/types/invariant.js` only when published), `src/index.ts` (empty node-half apply), optional `src/invariant.ts`, `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section and the reason when no invariant is published. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph). 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 6be2c4aa21..dbfad10f38 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: af757608aa7face6854aaf9d103654f974b51ed4 -README.zh.md: fef7248abe1e19595b90c43a6a1b9abf0aafa88a +README.md: 90731a3ff28aa6036949cad15ad76d1b135dc8fb +README.zh.md: 6f4a0adedf196872ef5639057db688c2840b46a7 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index af757608aa..90731a3ff2 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -72,3 +72,5 @@ None; this package neither assembles nor sends a provider request. None. + +**Runtime invariant:** No companion is published. Browser-session verification reads the credential record asynchronously at the request that authorizes work, while the credentials companion owns record commit-event lifetime. Stream/reconnect sequencing and rpcId round-trip discipline are exercised directly by behavior specs, and route register/dispose symmetry is audited by the webserver companion. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index fef7248abe..6f4a0adedf 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -72,3 +72,5 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation 无。 + +**运行时不变式:** 不发布伴生入口。授权请求会异步读取 credential 权威记录,commit-event 生命周期由 credentials 伴生入口负责;流、重连、rpcId 与路由释放关系由行为测试及 webserver 不变式覆盖。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index f19f30e40d..0bbe81e2d4 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -18,10 +18,6 @@ "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" @@ -43,7 +39,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], @@ -55,7 +50,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", @@ -69,7 +63,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts deleted file mode 100644 index 3b96a053eb..0000000000 --- a/packages/client/connection/src/invariant.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-connection`. - * @module @deepseek-ai/dsh-client-connection/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-client-connection' - -/** Cordis companion plugin name. */ -export const name = 'client-connection-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: browser-session verification reads the credential - * record asynchronously at the request that authorizes work, while the - * credentials companion owns record commit-event lifetime. Stream/reconnect - * sequencing and rpcId round-trip discipline are exercised directly by - * behavior specs, and route register/dispose symmetry is - * audited by the webserver companion. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 5732fd31f1..1ce037462f 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -47,9 +47,6 @@ { "path": "../../llm/llm" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/brand" } diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index baeeccf4a4..abc2e6985e 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -11,13 +11,18 @@ "src/browser-auth.ts", "src/http-bridge.ts", "src/index.ts", - "src/invariant.ts", "src/loopback-hostname.ts", "src/rpc-host.ts", "src/rpc-schema.ts", "src/rpc.ts" ], "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../attachment/attachment" }, @@ -30,9 +35,6 @@ { "path": "../../host/webserver" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/brand" } diff --git a/packages/client/connection/tsdown.config.ts b/packages/client/connection/tsdown.config.ts index 9be4570fb8..69cd7ce36d 100644 --- a/packages/client/connection/tsdown.config.ts +++ b/packages/client/connection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 79f896cd5b..a159c6cc4f 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: 7c2264bb59165c7c4e213516b4337fd5bc854bf8 -README.zh.md: 1693f7fce4b3aab33e79d1fe20943050ff992188 +README.md: da0931ff5cf78b16d57354a8ac6abe6bf1878e50 +README.zh.md: 18eb233e80ba8a68621b2fa34442cdda3c4329a0 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 7c2264bb59..da0931ff5c 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -138,3 +138,5 @@ These limits define where localization is incomplete or frozen at registration t None. + +**Runtime invariant:** No companion is published. The locale catalog and dictionaries have no independent runtime source to compare against; registration disposal, preference resolution, and fallback lookup are asserted by behavior specs. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 1693f7fce4..18eb233e80 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -138,3 +138,5 @@ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意 无。 + +**运行时不变式:** 不发布伴生入口。locale catalog 与字典没有可供交叉核对的独立运行时来源;注册释放、偏好解析和 fallback 查找由行为测试覆盖。 diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 466734cc9b..415001f490 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -18,10 +18,6 @@ "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" @@ -48,7 +44,6 @@ "@deepseek-ai/dsh-client-connection": "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:^" }, "devDependencies": { @@ -60,7 +55,6 @@ "@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:^", "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -71,7 +65,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], diff --git a/packages/client/locale/src/invariant.ts b/packages/client/locale/src/invariant.ts deleted file mode 100644 index c0a8555db9..0000000000 --- a/packages/client/locale/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`. - * @module @deepseek-ai/dsh-client-locale/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-client-locale' - -/** Cordis companion plugin name. */ -export const name = 'client-locale-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the locale catalog and dictionaries have no - * independent runtime source to compare against; registration disposal, - * preference resolution, and fallback lookup are asserted by behavior specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/locale/tests/invariant.client.spec.ts b/packages/client/locale/tests/invariant.client.spec.ts deleted file mode 100644 index d9b1eb041e..0000000000 --- a/packages/client/locale/tests/invariant.client.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' -import { apply as clientApply, COMMON_NS, LocaleRuntime, inject } from '@deepseek-ai/dsh-client-locale/client' -import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' -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' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply tolerates a Host without settings', () => { - nodeApply(new Context()) - }) - - it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { - // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) - const ctx = new Context() - new SlotRegistry(ctx) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) - // The settings row's transport and the forwarded-event port. - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin({ inject, apply: clientApply }).await() - const locale = ctx.get('locale') - expect(locale).toBeInstanceOf(LocaleRuntime) - // Seeded dictionaries occupy the (ns, locale) seats even while empty. - expect(() => (locale as LocaleRuntime).register(COMMON_NS, 'zh', {})).toThrow('already has locale') - expect(() => (locale as LocaleRuntime).register(COMMON_NS, 'en', {})).toThrow('already has locale') - }) -}) diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index f2dd8c6f0c..8737cc0c01 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../../vendor/schemastery" + }, { "path": "../store" }, @@ -26,9 +29,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../ui-settings" } diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts index 2141970e5d..8babf9f1eb 100644 --- a/packages/client/locale/tsdown.config.ts +++ b/packages/client/locale/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js']) diff --git a/packages/client/store/README.i18n.yaml b/packages/client/store/README.i18n.yaml index 3ff087b63d..d6af2417f1 100644 --- a/packages/client/store/README.i18n.yaml +++ b/packages/client/store/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/store/README.md -README.md: 49990551a47e81cdeeeeee0daa56b6fadb956fe6 -README.zh.md: 97c29b9f776c06c5b8eddd382d1d3dc58e29a8d5 +README.md: 9d675c1189ae194b913293c89904b4c4f39c879e +README.zh.md: 3ff170b71c320688e50c02c20479ee1d94061a87 diff --git a/packages/client/store/README.md b/packages/client/store/README.md index 49990551a4..9d675c1189 100644 --- a/packages/client/store/README.md +++ b/packages/client/store/README.md @@ -43,3 +43,5 @@ None; the stores neither assemble nor send model requests. None. + +**Runtime invariant:** No companion is published. The package exports a library engine and creates no process-global state; each store instance is covered by its owning tests. diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md index 97c29b9f77..3ff170b71c 100644 --- a/packages/client/store/README.zh.md +++ b/packages/client/store/README.zh.md @@ -43,3 +43,5 @@ kind: "package-library" 无。 + +**运行时不变式:** 不发布伴生入口。本包只导出库引擎,不创建进程级状态;每个 store 实例由其所属测试覆盖。 diff --git a/packages/client/store/package.json b/packages/client/store/package.json index e6b8eb47e6..d60d4fc92d 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -18,10 +18,6 @@ "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" }, @@ -31,16 +27,13 @@ "zustand": "~4.4.7" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ] } diff --git a/packages/client/store/src/invariant.ts b/packages/client/store/src/invariant.ts deleted file mode 100644 index 6c9425d097..0000000000 --- a/packages/client/store/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-store`. - * @module @deepseek-ai/dsh-client-store/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-client-store' - -/** Cordis companion plugin name. */ -export const name = 'client-store-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package exports a library engine and creates no - * process-global state; each store instance is covered by its owning tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/store/tests/invariant.client.spec.ts b/packages/client/store/tests/invariant.client.spec.ts deleted file mode 100644 index 779422ea82..0000000000 --- a/packages/client/store/tests/invariant.client.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { describe, expect, it } from 'vitest' -import * as StoreInvariant from '../src/invariant.ts' - -describe('store invariant companion', () => { - it('registers the package-owned empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(StoreInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/store/tsconfig.json b/packages/client/store/tsconfig.json index 66c8817e0e..4e6197e0d1 100644 --- a/packages/client/store/tsconfig.json +++ b/packages/client/store/tsconfig.json @@ -10,9 +10,6 @@ "references": [ { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/store/tsdown.config.ts b/packages/client/store/tsdown.config.ts index d6757c05f8..b505d0388d 100644 --- a/packages/client/store/tsdown.config.ts +++ b/packages/client/store/tsdown.config.ts @@ -2,5 +2,5 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( '@deepseek-ai/dsh-client-store', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 042caf6f1b..2eece63a13 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 60a7f0ec9356c6107a32bb7c828746d54511d0f6 -README.zh.md: 64663fd76e0a8e3fd4cb799b05e81db7b62c0bde +README.md: bbddefce7fb0805998eb551ac9f2d470c313a549 +README.zh.md: aff13a8c852e116662996ab3da0b753cf875a235 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 60a7f0ec93..bbddefce7f 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -90,3 +90,5 @@ These limits define the current preset surfaces. They are current package constr None. + +**Runtime invariant:** No companion is published. This is a browser-side surface plugin whose node half owns no event stream or mutable runtime data; the roster and the settings write are host contracts covered there. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 64663fd76e..aff13a8c85 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -90,3 +90,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。这是浏览器侧 surface 插件,node half 不拥有事件流或可变运行时数据;roster 与 settings 写入属于 Host 约定。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 7ec49fd96f..6210186ada 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -18,10 +18,6 @@ "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" @@ -59,7 +55,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@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-workspace": "workspace:^" @@ -76,7 +71,6 @@ "@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:^", "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -87,7 +81,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts deleted file mode 100644 index 8420348123..0000000000 --- a/packages/client/ui-agent-preset/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`. - * @module @deepseek-ai/dsh-client-ui-agent-preset/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-client-ui-agent-preset' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-agent-preset-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream - * or mutable runtime data; the roster and the settings write are host contracts covered there. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index c2488de4af..bb8f95eeb4 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -23,6 +23,7 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' import { AgentPresetSeatController } from '../src/client/seat-store.ts' +import { apply as hostApply } from '../src/index.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -175,6 +176,10 @@ function sessionsDouble(state: { } describe('ui-agent-preset apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares the services it uses', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', diff --git a/packages/client/ui-agent-preset/tests/invariant.client.spec.ts b/packages/client/ui-agent-preset/tests/invariant.client.spec.ts deleted file mode 100644 index 768a64871f..0000000000 --- a/packages/client/ui-agent-preset/tests/invariant.client.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** The package's node half: an empty host body and an explained empty invariant companion. */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant' - -describe('invariant companion', () => { - it('reserves package ownership with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined() - }) - - it('has an empty node half', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset') - - // The host body exists only so the plugin appears in the host cordis.yml; - // every surface this package ships lives in the browser half. - apply() - - expect(typeof apply).toBe('function') - }) -}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 3aa26af84e..c0538642fe 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -41,9 +41,6 @@ { "path": "../ui-slots" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts index 3ede4df6a8..3f5cc0a823 100644 --- a/packages/client/ui-agent-preset/tsdown.config.ts +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js']) diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index 7e27b147ac..a1f577b551 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/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-approval/README.md -README.md: 7ee2a294587ce8050cf58f6ce16b5b6d0f9c9128 -README.zh.md: 2cd9731957a625081bd2d7318f0d55f5c1a033c5 +README.md: 1d5a4a56830c34693ee8cc93c6805ec8dd9a8191 +README.zh.md: 60815e398d2428fcdf4d72b9d9c7e662ba7cda45 diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index 7ee2a29458..1d5a4a5683 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -43,3 +43,5 @@ None; approval request and response rendering does not alter a model request. None. + +**Runtime invariant:** No companion is published. Registries own and observe the Remote listener and temporary Slot entry. diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index 2cd9731957..60815e398d 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -43,3 +43,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。Remote listener 与临时 Slot entry 由各自注册表持有并观察。 diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 060cb0f386..65711701f8 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -18,10 +18,6 @@ "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" @@ -55,7 +51,6 @@ "@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-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" @@ -70,7 +65,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -79,7 +73,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-approval/src/invariant.ts b/packages/client/ui-approval/src/invariant.ts deleted file mode 100644 index 833270df30..0000000000 --- a/packages/client/ui-approval/src/invariant.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Package-owned invariant companion for the approval presentation plugin. */ -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-approval' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-approval-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: registries own and observe the Remote listener and temporary Slot entry. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns The installed registration's disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ 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 2a550ef514..8bb32f5ebe 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -11,7 +10,6 @@ import type { ApprovalComposerProps } from '../src/client/contract/slots.ts' import { PendingApproval } from '../src/client/contract/slots.ts' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' -import * as ApprovalInvariant from '../src/invariant.ts' type ApprovalListener = ( this: Context, @@ -375,12 +373,8 @@ describe('ApprovalPanel', () => { }) describe('package entries', () => { - it('declares its service edges, keeps the Host half inert, and registers its invariant', async () => { + it('declares its service edges and keeps the Host half inert', () => { expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale']) expect(() => { nodeApply() }).not.toThrow() - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(ApprovalInvariant).await()).resolves.toBeDefined() }) }) diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json index f7b719a444..5879d8e502 100644 --- a/packages/client/ui-approval/tsconfig.json +++ b/packages/client/ui-approval/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../llm/llm" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../typert/protocol" }, diff --git a/packages/client/ui-approval/tsdown.config.ts b/packages/client/ui-approval/tsdown.config.ts index f7e798c609..4ddd1fdffe 100644 --- a/packages/client/ui-approval/tsdown.config.ts +++ b/packages/client/ui-approval/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js']) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 5e0e50e06a..8079b51518 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-attachment/README.md -README.md: 9fa03432b43686dd55af494640708a8bf0983f9a -README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 +README.md: 3fce1c01b6acaa204c92ff240122233c8eb8c388 +README.zh.md: 65f43dd73412703c7cad0174f0f93b1e6eba0c1c diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 9fa03432b4..3fce1c01b6 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -102,3 +102,5 @@ These limits define the current attachment surface. They are package constraints None. + +**Runtime invariant:** No companion is published. The package contributes only effect-owned slot entries; the slot registry owns their lifecycle and validates their declarations. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 48e467280b..65f43dd734 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -102,3 +102,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包只贡献 effect 所有的 slot entry;slot 注册表负责其生命周期并校验声明。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 68efaafcd5..ac0fb28edb 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -18,10 +18,6 @@ "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" @@ -50,7 +46,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", @@ -65,13 +60,11 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts deleted file mode 100644 index 5358704929..0000000000 --- a/packages/client/ui-attachment/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`. - * @module @deepseek-ai/dsh-client-ui-attachment/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-client-ui-attachment' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-attachment-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package contributes only effect-owned slot entries; - * the slot registry owns their lifecycle and validates their declarations. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-attachment/tests/invariant.client.spec.ts b/packages/client/ui-attachment/tests/invariant.client.spec.ts deleted file mode 100644 index 51dc507c8a..0000000000 --- a/packages/client/ui-attachment/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index b2783b20e9..6ce268c9a1 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../attachment/attachment" + "path": "../../../vendor/cordis" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../attachment/attachment" }, { "path": "../ui-renderer" diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index e70803de17..2c903ce9af 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -2,5 +2,5 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( '@deepseek-ai/dsh-client-ui-attachment', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml index e29dcc623f..15431d3d0b 100644 --- a/packages/client/ui-brand-official/README.i18n.yaml +++ b/packages/client/ui-brand-official/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-brand-official/README.md -README.md: 03712e826fb415dd6fb3125c06865a2402342948 -README.zh.md: 94e8a30bb11abd468a59cc80b6745d5d929a1e23 +README.md: fd249851249ab065249bba8a2de70da938a45fbe +README.zh.md: 97d3bf9567534ce9a0269afaeeaa749cfca15b02 diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md index 03712e826f..fd24985124 100644 --- a/packages/client/ui-brand-official/README.md +++ b/packages/client/ui-brand-official/README.md @@ -88,3 +88,5 @@ These limits define how brand presentation is supplied. They are current package None. + +**Runtime invariant:** No companion is published. The package retains no mutable state, and its three slot occupants install and leave through one transactional effect. diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md index 94e8a30bb1..97d3bf9567 100644 --- a/packages/client/ui-brand-official/README.zh.md +++ b/packages/client/ui-brand-official/README.zh.md @@ -88,3 +88,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包不保留可变状态,三个 slot occupant 通过同一个事务性 effect 安装和释放。 diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index b40b6d8664..845da5b0c7 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -18,10 +18,6 @@ "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" @@ -48,7 +44,6 @@ "@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": { @@ -56,7 +51,6 @@ "@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:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", @@ -65,7 +59,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-brand-official/src/invariant.ts b/packages/client/ui-brand-official/src/invariant.ts deleted file mode 100644 index 574054c383..0000000000 --- a/packages/client/ui-brand-official/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-brand-official`. - * @module @deepseek-ai/dsh-client-ui-brand-official/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-client-ui-brand-official' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-brand-official-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package retains no mutable state, and its three - * slot occupants install and leave through one transactional effect. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx index 8fa5ca8056..3053a098ca 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 @@ -5,6 +5,7 @@ import { cleanup, render } from '@testing-library/react' 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' +import { apply as hostApply } from '../src/index.ts' afterEach(() => { cleanup() @@ -30,6 +31,10 @@ async function bench(declare = true) { } describe('official browser-brand plugin', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares only the slot service it uses', () => { expect(inject).toEqual(['slots']) }) diff --git a/packages/client/ui-brand-official/tests/invariant.client.spec.ts b/packages/client/ui-brand-official/tests/invariant.client.spec.ts deleted file mode 100644 index d8d3dd2d95..0000000000 --- a/packages/client/ui-brand-official/tests/invariant.client.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { describe, expect, it } from 'vitest' -import * as BrandInvariant from '../src/invariant.ts' -import { apply as nodeApply } from '../src/index.ts' - -describe('official brand invariant companion', () => { - it('reserves package ownership with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(BrandInvariant).await()).resolves.toBeDefined() - }) - - it('keeps the node half as an inert Loader seat', () => { - expect(() => { nodeApply() }).not.toThrow() - }) -}) diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json index 0ba9d872dd..232b6256f3 100644 --- a/packages/client/ui-brand-official/tsconfig.json +++ b/packages/client/ui-brand-official/tsconfig.json @@ -9,7 +9,7 @@ ], "references": [ { - "path": "../../runtime-diagnostics/invariants" + "path": "../../../vendor/cordis" }, { "path": "../ui-renderer" diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts index abc830c1f6..9edc90cbc9 100644 --- a/packages/client/ui-brand-official/tsdown.config.ts +++ b/packages/client/ui-brand-official/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index a38a72ef3e..20dbbf7b12 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 5c7549da07e8101c6b08b4cad57afd5158980b7f -README.zh.md: a558bf94f5436eebdf035ca7507ecd25ec3c8d8a +README.md: 9fae74daa05c45276f6932434bc0b687a9484a85 +README.zh.md: dbe26d7d6f2db37704391afb2d93efa7f26e66cb diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5c7549da07..9fae74daa0 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -67,3 +67,5 @@ None; Chat presentation does not assemble or mutate provider requests. None. + +**Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index a558bf94f5..dbe26d7d6f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -67,3 +67,5 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真 无。 + +**运行时不变式:** 不发布伴生入口。Conversation 与 Slot 注册已经强制 Chat target 一致。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 992ea08102..8d34d2f4b2 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -18,10 +18,6 @@ "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" @@ -67,7 +63,6 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -97,7 +92,6 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -114,7 +108,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-chat/src/invariant.ts b/packages/client/ui-chat/src/invariant.ts deleted file mode 100644 index d9e06d4f59..0000000000 --- a/packages/client/ui-chat/src/invariant.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Package-owned invariant companion for the Chat UI target. */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-chat' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-chat-invariant' -/** Service required before the companion reserves package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: Conversation and Slot registration enforce Chat target consistency. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx index ebe9359b68..2273e3d179 100644 --- a/packages/client/ui-chat/tests/approval-command.client.spec.tsx +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -1,13 +1,11 @@ // @vitest-environment jsdom import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import type { ChatSnapshot, UseChat } from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { ApprovalCommand, commandOf } from '../src/client/chat/ApprovalCommand.tsx' import { apply as nodeApply } from '../src/index.ts' -import * as ChatInvariant from '../src/invariant.ts' function props( nodes: readonly unknown[], @@ -61,11 +59,8 @@ describe('ApprovalCommand', () => { }) describe('ui-chat package entries', () => { - it('keeps the Host half optional and registers the invariant companion', async () => { + it('keeps the Host half optional', () => { const ctx = new Context() expect(() => { nodeApply(ctx) }).not.toThrow() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined() }) }) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 4d42320885..a7ce8b6d72 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../api/remotes/tsconfig.client.json" }, @@ -47,9 +50,6 @@ { "path": "../../llm/token-meter" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/workspace-path" }, diff --git a/packages/client/ui-chat/tsdown.config.ts b/packages/client/ui-chat/tsdown.config.ts index dd950f315d..ebf7099b8f 100644 --- a/packages/client/ui-chat/tsdown.config.ts +++ b/packages/client/ui-chat/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js']) diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 4b0044abd0..be9c1f8b43 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/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-commands/README.md -README.md: 0c1c3e8310177f3f26678afdf414baf2543e798d -README.zh.md: 8699f5b213d634c3ad06b3f48a4d6c1c1e16fbb4 +README.md: b27c795eaf9a7954925961bde81872307b9f3d97 +README.zh.md: 65d31d0f390fce0ea341bf3391903539d3e0d0f7 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 0c1c3e8310..b27c795eaf 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -89,3 +89,5 @@ These limits define the current command surface. They are current package constr None. + +**Runtime invariant:** No companion is published. A browser-side source over the wire command directory — it emits no cordis events and owns no cross-plugin mutable state; dispatch and cache behavior are asserted by this package's specs. diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 8699f5b213..65d31d0f39 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -89,3 +89,5 @@ composer 携带图片附件提交时,只有声明了 `input.images` 的宿主 无。 + +**运行时不变式:** 不发布伴生入口。这是基于 wire command directory 的浏览器侧 source,不发出 Cordis 事件,也不持有跨插件可变状态;dispatch 与 cache 行为由包测试覆盖。 diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index a5a15451f2..d7fb28436e 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -18,10 +18,6 @@ "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" @@ -54,7 +50,6 @@ "@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/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -71,7 +66,6 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", @@ -83,7 +77,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-commands/src/invariant.ts b/packages/client/ui-commands/src/invariant.ts deleted file mode 100644 index 510c91e204..0000000000 --- a/packages/client/ui-commands/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-commands`. - * @module @deepseek-ai/dsh-client-ui-commands/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-client-ui-commands' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-commands-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a browser-side source over the wire command - * directory — it emits no cordis events and owns no cross-plugin mutable - * state; dispatch and cache behavior are asserted by this package's specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-commands/tsconfig.json b/packages/client/ui-commands/tsconfig.json index f4c9d3b26a..3cc194a464 100644 --- a/packages/client/ui-commands/tsconfig.json +++ b/packages/client/ui-commands/tsconfig.json @@ -46,9 +46,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-commands/tsdown.config.ts b/packages/client/ui-commands/tsdown.config.ts index 3e2de1ee96..83f95f8a8c 100644 --- a/packages/client/ui-commands/tsdown.config.ts +++ b/packages/client/ui-commands/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-commands', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-commands', ['lib/types/index.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a293070d39..43be5429e7 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: bcce1e77c45cfa75c6a7fe9c45a98b1036d9ea63 -README.zh.md: 1d24141945c68d7e948a730ff5b93e8d2e38e9bb +README.md: 08a82b505eda855b7279235267ba3836d1f02ef7 +README.zh.md: b70b683c399dcf641bef17d55673787e57220d6f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bcce1e77c4..08a82b505e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -117,3 +117,5 @@ None; Conversation assembly and browser input state do not alter provider-side p None. + +**Runtime invariant:** No companion is published. Conversation Definitions, target builders, and Views are already validated by their owning registries and the Slot ledger. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1d24141945..b70b683c39 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -117,3 +117,5 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match 无。 + +**运行时不变式:** 不发布伴生入口。Conversation Definition、target builder 与 View 已由其所属注册表和 Slot ledger 校验。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e9e434071b..2efff15339 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -18,10 +18,6 @@ "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" @@ -72,7 +68,6 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", @@ -104,7 +99,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", @@ -125,7 +119,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts deleted file mode 100644 index db950ebd65..0000000000 --- a/packages/client/ui-conversation/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-conversation`. - * @module @deepseek-ai/dsh-client-ui-conversation/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-client-ui-conversation' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-conversation-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: Conversation Definitions, target builders, and Views - * are already validated by their owning registries and the Slot ledger. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 020f0568c2..c4d11ddb3d 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../../vendor/schemastery" + }, { "path": "../../api/remotes/tsconfig.client.json" }, @@ -77,9 +80,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../interaction/permission-presets" }, diff --git a/packages/client/ui-conversation/tsdown.config.ts b/packages/client/ui-conversation/tsdown.config.ts index 3a2c2da24d..6ccfee9612 100644 --- a/packages/client/ui-conversation/tsdown.config.ts +++ b/packages/client/ui-conversation/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-conversation', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-conversation', ['lib/types/index.js']) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 60f05a20e3..ee98f91817 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 04f9ff96c1573eb66a64de1d174e257c3a3608a2 -README.zh.md: f960d3da993fee3166486d8407c32e4f18278153 +README.md: ac56ae782ac13664fdfb32c51ea07e2f8f9818d7 +README.zh.md: 1aefe0d3c94f3d1c93c193426fd3d21b50359c75 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 04f9ff96c1..ac56ae782a 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -98,3 +98,5 @@ These limits define the current deliverables vocabulary. They are current packag None. + +**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, event definition, and optional service registrations are effect-owned with disposal proven by their plugin specs; this package owns no mutable state. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index f960d3da99..1aefe0d3c9 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -98,3 +98,5 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 无。 + +**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index a977a5e609..4b1372f6ad 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -18,10 +18,6 @@ "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" @@ -54,7 +50,6 @@ "@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/dsh-session": "workspace:^" @@ -69,7 +64,6 @@ "@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:^", @@ -79,7 +73,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts deleted file mode 100644 index 7fa5eaa216..0000000000 --- a/packages/client/ui-deliverables/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-deliverables`. - * @module @deepseek-ai/dsh-client-ui-deliverables/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-client-ui-deliverables' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-deliverables-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the prompt section, slot, dictionary, event - * definition, and optional service registrations are effect-owned with - * disposal proven by their plugin specs; this package owns no mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 5a0043971e..b3075cfed3 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -29,7 +29,6 @@ import { type DeliverablesTurnData, } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' -import { apply as applyInvariant } from '../src/invariant.ts' import { en, zh } from '../src/client/locales.ts' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' @@ -554,19 +553,6 @@ describe('producedFileMentions resolver', () => { }) }) -describe('package shells', () => { - it('the invariant companion registers ownership', async () => { - const registered: string[] = [] - const ctx = new Context() - ctx.provide('invariants') - ctx.set('invariants', { - register: (pkg: string) => { registered.push(pkg); return () => {} }, - } as never) - const dispose = await applyInvariant(ctx) - expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables']) - expect(dispose).toBeTypeOf('function') - }) -}) describe('plugin registration', () => { it('registers the tail entry and fiber disposal removes it', async () => { diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 9c29b3efdc..a99881d93b 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -38,9 +38,6 @@ { "path": "../ui-slots" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/system-prompt" }, diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts index ce1a8cefcc..51d0ff8a1e 100644 --- a/packages/client/ui-deliverables/tsdown.config.ts +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js']) diff --git a/packages/client/ui-directory-picker-browse/README.i18n.yaml b/packages/client/ui-directory-picker-browse/README.i18n.yaml index 37f7968012..c81778edf0 100644 --- a/packages/client/ui-directory-picker-browse/README.i18n.yaml +++ b/packages/client/ui-directory-picker-browse/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-directory-picker-browse/README.md -README.md: 7b62b297b696ff01fd07ea45dc3a3f177c937522 -README.zh.md: 58f775834ca9611d5645138fcb8b8c42f813345b +README.md: 0e760fa51c7eb4e4c7719c3098233f4905a6144e +README.zh.md: c68a42ce3ecd7113cbd7f490ec90e0388c8141a6 diff --git a/packages/client/ui-directory-picker-browse/README.md b/packages/client/ui-directory-picker-browse/README.md index 7b62b297b6..0e760fa51c 100644 --- a/packages/client/ui-directory-picker-browse/README.md +++ b/packages/client/ui-directory-picker-browse/README.md @@ -85,3 +85,5 @@ These limits define the current browse surface. They are current package constra None. + +**Runtime invariant:** No companion is published. The plugin registers one workspace directory-flow owner whose disposal the HMR-safety spec proves, and every listing it shows is re-read from the Host on demand rather than held here. diff --git a/packages/client/ui-directory-picker-browse/README.zh.md b/packages/client/ui-directory-picker-browse/README.zh.md index 58f775834c..c68a42ce3e 100644 --- a/packages/client/ui-directory-picker-browse/README.zh.md +++ b/packages/client/ui-directory-picker-browse/README.zh.md @@ -85,3 +85,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件只注册一个 workspace directory-flow owner,HMR 测试覆盖释放;显示的目录内容每次都从 Host 重读。 diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 90cd0ce667..1f4f374266 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -18,10 +18,6 @@ "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" @@ -53,7 +49,6 @@ "@deepseek-ai/dsh-client-locale": "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": { @@ -64,7 +59,6 @@ "@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:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -73,7 +67,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-directory-picker-browse/src/invariant.ts b/packages/client/ui-directory-picker-browse/src/invariant.ts deleted file mode 100644 index cf2dda942b..0000000000 --- a/packages/client/ui-directory-picker-browse/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-directory-picker-browse`. - * @module @deepseek-ai/dsh-client-ui-directory-picker-browse/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-client-ui-directory-picker-browse' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-directory-picker-browse-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the plugin registers one workspace directory-flow - * owner whose disposal the HMR-safety spec proves, and every listing it shows - * is re-read from the Host on demand rather than held here. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index f98d959478..782ae4d533 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 @@ -224,8 +224,6 @@ describe('directory-picker-browse client half', () => { }) describe('directory-picker-browse node half', () => { - // The invariant companion is mounted by the vitest-wide invariant host on - // every Context this suite creates; its registration is covered there. it('the node apply is an inert loader seat', () => { expect(() => { nodeApply() }).not.toThrow() }) diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index aaa77a76d9..acef96ad6a 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -9,7 +9,7 @@ ], "references": [ { - "path": "../../runtime-diagnostics/invariants" + "path": "../../../vendor/cordis" }, { "path": "../ui-slots" diff --git a/packages/client/ui-directory-picker-browse/tsdown.config.ts b/packages/client/ui-directory-picker-browse/tsdown.config.ts index 8bc08e5c18..840d57706f 100644 --- a/packages/client/ui-directory-picker-browse/tsdown.config.ts +++ b/packages/client/ui-directory-picker-browse/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-browse', ['lib/types/index.js']) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 33650e5e6d..0b51702214 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/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-directory-picker-native/README.md -README.md: 56df434a1ddf386c44bb0818d3ca7c267b7e2695 -README.zh.md: 9b19ec76538df10fbfa6ede04b8c00d2e5f1ed0c +README.md: 4a7107e3cf4a6383eb975d3b75457fb471ac26b0 +README.zh.md: 3e9363e4cebec59caa17f3dee61aa35f3a6eb2bb diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index 56df434a1d..4a7107e3cf 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -85,3 +85,5 @@ These limits define when the native chooser fits. They are current package const None. + +**Runtime invariant:** No companion is published. The plugin registers a renderless flow occupant into two workspace holes as one transactional effect, whose disposal the HMR-safety spec proves, and it retains no state between picks. diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 9b19ec7653..3e9363e4ce 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -85,3 +85,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件用一个事务性 effect 把无渲染 flow occupant 注册到两个 workspace hole,HMR 测试覆盖释放,pick 之间不保留状态。 diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 9bb352e7cb..0ebe194395 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -18,10 +18,6 @@ "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" @@ -46,13 +42,11 @@ "peerDependencies": { "@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-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -61,7 +55,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-directory-picker-native/src/invariant.ts b/packages/client/ui-directory-picker-native/src/invariant.ts deleted file mode 100644 index 6916d03522..0000000000 --- a/packages/client/ui-directory-picker-native/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-directory-picker-native`. - * @module @deepseek-ai/dsh-client-ui-directory-picker-native/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-client-ui-directory-picker-native' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-directory-picker-native-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the plugin registers a renderless flow occupant into - * two workspace holes as one transactional effect, whose disposal the - * HMR-safety spec proves, and it retains no state between picks. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 2cfd1350ca..14b800a5df 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 @@ -228,8 +228,6 @@ describe('directory-picker-native client half', () => { }) describe('directory-picker-native node half', () => { - // The invariant companion is mounted by the vitest-wide invariant host on - // every Context this suite creates; its registration is covered there. it('the node apply is an inert loader seat', () => { expect(() => { nodeApply() }).not.toThrow() }) diff --git a/packages/client/ui-directory-picker-native/tsconfig.json b/packages/client/ui-directory-picker-native/tsconfig.json index 75e7cfbb4e..c57a21a081 100644 --- a/packages/client/ui-directory-picker-native/tsconfig.json +++ b/packages/client/ui-directory-picker-native/tsconfig.json @@ -9,7 +9,7 @@ ], "references": [ { - "path": "../../runtime-diagnostics/invariants" + "path": "../../../vendor/cordis" }, { "path": "../ui-slots" diff --git a/packages/client/ui-directory-picker-native/tsdown.config.ts b/packages/client/ui-directory-picker-native/tsdown.config.ts index 94d4ed5ea8..23e5247c7e 100644 --- a/packages/client/ui-directory-picker-native/tsdown.config.ts +++ b/packages/client/ui-directory-picker-native/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js']) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 2603c648b3..096dec8b0d 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: 05c44fee27f0500c6a5d53496fde3d447edbf424 -README.zh.md: f280356eb37b364f49851ca24d73370eb62e6795 +README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020 +README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 05c44fee27..1a2c65759f 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -88,3 +88,5 @@ These limits define the current goal surface. They are current package constrain None. + +**Runtime invariant:** No companion is published. A single GoalBar dock registration whose disposal is proven by the HMR-safety spec — the plugin owns no store (state arrives on the goal projection), emits no cordis events, and holds no cross-plugin mutable state. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index f280356eb3..feebeaff0b 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -88,3 +88,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件只注册一个 GoalBar dock,HMR 测试覆盖释放;状态来自 goal projection,本包不持有 store 或跨插件可变状态。 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 1bd3457b73..dc9571f450 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -18,10 +18,6 @@ "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" @@ -58,7 +54,6 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" @@ -76,7 +71,6 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -87,7 +81,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-goal/src/invariant.ts b/packages/client/ui-goal/src/invariant.ts deleted file mode 100644 index 93d0602d25..0000000000 --- a/packages/client/ui-goal/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-goal`. - * @module @deepseek-ai/dsh-client-ui-goal/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-client-ui-goal' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-goal-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a single GoalBar dock registration whose disposal is - * proven by the HMR-safety spec — the plugin owns no store (state arrives on - * the goal projection), emits no cordis events, and holds no cross-plugin - * mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx index cb2cf0a4be..5befe8bc17 100644 --- a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx @@ -7,8 +7,7 @@ * Remote method's compare-and-set is the guard), a missing projection short-circuits * to the no-current-goal error without touching the wire, and a Remote failure * reaches the strip verbatim. Registration disposal rides the - * plugin fiber (HMR safety). The node half and the invariant companion are - * exercised over the same Context. + * plugin fiber (HMR safety), and the node half stays inert. */ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' @@ -236,8 +235,6 @@ describe('GoalDock adapter', () => { }) describe('ui-goal node half', () => { - // The invariant companion is mounted by the vitest-wide invariant host on - // every Context this suite creates; its registration is covered there. it('the node apply is an inert loader seat', () => { expect(() => { nodeApply() }).not.toThrow() }) diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 0e81d8e171..3f83c77012 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -46,9 +46,6 @@ }, { "path": "../../typert/protocol" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-goal/tsdown.config.ts b/packages/client/ui-goal/tsdown.config.ts index a01975f78a..8b16051076 100644 --- a/packages/client/ui-goal/tsdown.config.ts +++ b/packages/client/ui-goal/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js']) diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 23b3ddafb1..ab4c20a0e0 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/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-input-trigger/README.md -README.md: 1f316229327d33f48e0950a2c27e143c6c991f99 -README.zh.md: 324541120406eac8677afcfda0967540dd74848e +README.md: cdc040ec8d339fef2cd14f830587179903e70b8a +README.zh.md: 6b4793015142b51d2642a146ca8a53ad007dd660 diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 1f31622932..cdc040ec8d 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -86,3 +86,5 @@ These limits define the current trigger pipeline. They are current package const None. + +**Runtime invariant:** No companion is published. The trigger pipeline is a browser-side pure core (detect/reduce/match) plus a registry whose disposal is proven by the HMR-safety spec; it emits no cordis events and owns no cross-plugin mutable state. diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 3245411204..6b47930151 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -86,3 +86,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。trigger pipeline 是浏览器侧纯逻辑加一个 registry,HMR 测试覆盖释放;它不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 6114813aa3..6ccbdd839c 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -18,10 +18,6 @@ "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" @@ -52,7 +48,6 @@ "@deepseek-ai/dsh-client-locale": "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-api-session-controller": "workspace:^", @@ -66,7 +61,6 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", @@ -78,7 +72,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-input-trigger/src/invariant.ts b/packages/client/ui-input-trigger/src/invariant.ts deleted file mode 100644 index 16f2bf81cc..0000000000 --- a/packages/client/ui-input-trigger/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-input-trigger`. - * @module @deepseek-ai/dsh-client-ui-input-trigger/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-client-ui-input-trigger' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-input-trigger-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the trigger pipeline is a browser-side pure core - * (detect/reduce/match) plus a registry whose disposal is proven by the - * HMR-safety spec; it emits no cordis events and owns no cross-plugin - * mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-input-trigger/tsconfig.json b/packages/client/ui-input-trigger/tsconfig.json index 1570df8e63..cc18db0119 100644 --- a/packages/client/ui-input-trigger/tsconfig.json +++ b/packages/client/ui-input-trigger/tsconfig.json @@ -40,9 +40,6 @@ }, { "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-input-trigger/tsdown.config.ts b/packages/client/ui-input-trigger/tsdown.config.ts index d67fe834c6..86f4bfad7d 100644 --- a/packages/client/ui-input-trigger/tsdown.config.ts +++ b/packages/client/ui-input-trigger/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-input-trigger', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-input-trigger', ['lib/types/index.js']) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 3f549066d3..c1a781e24d 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: 3910d68286e9efa669ca343a196c3c0eddc949f3 -README.zh.md: df006199d7c3f19752789616356d15fec04a940d +README.md: f2ed20916fc04e1c1be7be933698c78d2ba92e1f +README.zh.md: b3fe6b7c754dee455ce2530a670792afc6e49b46 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 3910d68286..f2ed20916f 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -85,3 +85,5 @@ These limits define the current job list. They are current package constraints, None. + +**Runtime invariant:** No companion is published. This package is a read-only projection of the `jobsBySession` mirror onto one header slot entry. It emits no cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index df006199d7..b3fe6b7c75 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -85,3 +85,5 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理 无。 + +**运行时不变式:** 不发布伴生入口。本包只把 `jobsBySession` mirror 只读投影到一个 header slot,不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index e944070fe6..e4403c899e 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -10,10 +10,6 @@ "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" @@ -47,7 +43,6 @@ "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", @@ -59,7 +54,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", @@ -69,7 +63,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-jobs/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts deleted file mode 100644 index 0807c8600f..0000000000 --- a/packages/client/ui-jobs/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`. - * @module @deepseek-ai/dsh-client-ui-jobs/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-client-ui-jobs' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-jobs-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package is a read-only projection of the - * `jobsBySession` mirror onto one header slot entry. It emits no cordis - * events, owns no cross-plugin mutable state, and its single slot registration - * proves disposal through the HMR-safety spec. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 93e6df1e5f..db6245819f 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -1,18 +1,15 @@ /** * ui-job plugin halves: the browser entry's dictionary and header-slot * registrations against the real SlotRegistry (with fiber teardown proving - * removal — HMR safety), the inert node entry, and the invariant companion's - * ownership reservation. + * removal — HMR safety), and the inert node entry. */ 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-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' import { apply as applyNode } from '../src/index.ts' -import * as JobInvariant from '../src/invariant.ts' import { en, NS, zh } from '../src/client/locales.ts' /** Slot ledger reader: entry ids currently registered in the header list. */ @@ -83,19 +80,3 @@ describe('ui-job node half', () => { expect(applyNode).not.toThrow() }) }) - -describe('ui-job invariant companion', () => { - it('reserves package ownership under its declared companion name', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = ctx.plugin(JobInvariant) - await fiber.await() - expect(JobInvariant.name).toBe('client-ui-jobs-invariant') - expect(JobInvariant.inject).toEqual(['invariants']) - // Emitting an unrelated event proves the companion installed no audit. - expect(() => { - Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) - }).not.toThrow() - await fiber.dispose() - }) -}) diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json index 727523a055..0e66aa4958 100644 --- a/packages/client/ui-jobs/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-jobs/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts index 550cd558a8..550a1fcf58 100644 --- a/packages/client/ui-jobs/tsdown.config.ts +++ b/packages/client/ui-jobs/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js']) diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 24726ff51e..17187a8179 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 5eba9ac8ce793092053d9b8c84daa8f737597c26 -README.zh.md: 1af81cea580d97cfa89365bdd79659933c0b2ae1 +README.md: aa00242f1d63924b990cfec23bd0438332662b06 +README.zh.md: 801346967f9fbb031e5e6c2612dff84af79f2d45 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 5eba9ac8ce..aa00242f1d 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -86,3 +86,5 @@ These limits define the current layout behavior. They are current package constr None. + +**Runtime invariant:** No companion is published. The shell viewing-state store behind ctx.layout emits no cordis events; clamp/prune/concession-chain sequencing is asserted directly by this package's columns and service specs. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 1af81cea58..801346967f 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -86,3 +86,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。`ctx.layout` 后的 viewing-state store 不发出 Cordis 事件;clamp、prune 与 concession-chain 顺序由本包测试覆盖。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index ea634dbafa..fe0c54fddb 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -18,10 +18,6 @@ "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" @@ -50,7 +46,6 @@ "@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": { @@ -60,14 +55,12 @@ "@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:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-layout/src/invariant.ts b/packages/client/ui-layout/src/invariant.ts deleted file mode 100644 index 266b9e5b0f..0000000000 --- a/packages/client/ui-layout/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-layout`. - * @module @deepseek-ai/dsh-client-ui-layout/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-client-ui-layout' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-layout-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the shell viewing-state store behind ctx.layout emits - * no cordis events; clamp/prune/concession-chain - * sequencing is asserted directly by this package's columns and service specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index e546294445..da3056b5db 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -8,7 +8,6 @@ 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' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' -import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' beforeEach(() => { document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) @@ -99,21 +98,9 @@ describe('ui-layout client apply', () => { }) }) -describe('node half + invariant companion', () => { +describe('node half', () => { it('node apply is an intentional no-op (loader-managed lifecycle only)', () => { nodeApply() expect(true).toBe(true) // reaching here without throw is the contract }) - - it('invariant companion registers under the package name', async () => { - const register = vi.fn().mockReturnValue(() => {}) - const ctx = { invariants: { register } } as never - // The /invariant subpath types live in lib/types (build product); assert - // the API so the call stays typed where lint runs without a build. - const dispose = await (invariant as { apply: (ctx: never) => Promise<() => void> }).apply(ctx) - expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-client-ui-layout', expect.any(Function)) - // The installer is the declared no-op — calling it must not throw. - expect(() => { (register.mock.calls[0]![1] as (c: never) => void)(undefined as never) }).not.toThrow() - expect(dispose).toBeTypeOf('function') - }) }) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index ba35af9f86..79a7f1ce54 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../ui-session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-layout/tsdown.config.ts b/packages/client/ui-layout/tsdown.config.ts index 2b6bbfee86..27224c5803 100644 --- a/packages/client/ui-layout/tsdown.config.ts +++ b/packages/client/ui-layout/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-layout', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-layout', ['lib/types/index.js']) diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index c1f6a35506..06d31edb7f 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/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-message-feedback/README.md -README.md: 2610d0d362438977512ce753eecb66cb77497812 -README.zh.md: 58e4dc10d8d42c14cb711261b401d9cf3bec8f98 +README.md: 58e3bd3eeae2f5701a23dc3bdf7da94f72acfbba +README.zh.md: f04fda4f038c2c301b21a2332f4c20ce776a629c diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index 2610d0d362..58e3bd3eea 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -85,3 +85,5 @@ These limits define the current feedback surface. They are current package const None. + +**Runtime invariant:** No companion is published. The plugin owns one slot registration and one per-session controller map, both released by the same effect disposer. The lifecycle spec proves the registration is withdrawn and every controller is dropped when the owning fiber is disposed, so no second authority exists to check at runtime. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index 58e4dc10d8..f04fda4f03 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -85,3 +85,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件持有一个 slot 注册和按 Session 的 controller map,两者由同一 effect disposer 释放;生命周期测试已直接观察该关系。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 51ca031762..cbf85312c7 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -18,10 +18,6 @@ "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" @@ -51,7 +47,6 @@ "@deepseek-ai/dsh-client-locale": "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:^", @@ -68,7 +63,6 @@ "@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:^", @@ -83,7 +77,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-message-feedback/src/invariant.ts b/packages/client/ui-message-feedback/src/invariant.ts deleted file mode 100644 index a0e137a82a..0000000000 --- a/packages/client/ui-message-feedback/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-message-feedback`. - * @module @deepseek-ai/dsh-client-ui-message-feedback/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-client-ui-message-feedback' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-feedback-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the plugin owns one slot registration and one - * per-session controller map, both released by the same effect disposer. The - * lifecycle spec proves the registration is withdrawn and every controller is - * dropped when the owning fiber is disposed, so no second authority exists to - * check at runtime. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx index bd894ebacf..4788e218bc 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 @@ -5,8 +5,7 @@ * conversation.chat.assistant-actions, one controller per Session backs every * message in that Session, a reconnect refreshes only Sessions that were * already read, and registration plus controller disposal ride the plugin - * fiber (HMR safety). The node half and the invariant companion are exercised - * over the same Context. + * fiber (HMR safety). The node half stays inert. */ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' @@ -208,8 +207,6 @@ describe('ui-message-feedback browser plugin', () => { }) it('the node half applies without host-side behavior', () => { - // The invariant companion is mounted by the vitest-wide invariant host on - // every Context this suite creates; its registration is covered there. expect(() => { nodeApply() }).not.toThrow() }) }) diff --git a/packages/client/ui-message-feedback/tsconfig.json b/packages/client/ui-message-feedback/tsconfig.json index 36d5d1b762..e006e8fb91 100644 --- a/packages/client/ui-message-feedback/tsconfig.json +++ b/packages/client/ui-message-feedback/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../feedback/message-feedback" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/session" }, diff --git a/packages/client/ui-message-feedback/tsdown.config.ts b/packages/client/ui-message-feedback/tsdown.config.ts index 6febb290ae..2137857d99 100644 --- a/packages/client/ui-message-feedback/tsdown.config.ts +++ b/packages/client/ui-message-feedback/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-message-feedback', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-message-feedback', ['lib/types/index.js']) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 94fb6d87a7..557a132ef9 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/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-model-selection/README.md -README.md: 3883ebf860b9503e69df770e8d1dc8a8769fa9d0 -README.zh.md: 943a21a6815896e6cee7996bc05b32e938244fe1 +README.md: 0ad5d6ee21dd47757247617d0ca2579144ac9611 +README.zh.md: 9c0b72a1e83785fc0bdaa62ec9bd35df10353ce1 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 3883ebf860..0ad5d6ee21 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -90,3 +90,5 @@ These limits define the current model surface. They are current package constrai None. + +**Runtime invariant:** No companion is published. A single command contribution registration whose disposal is proven by the HMR-safety spec — it emits no cordis events and owns no cross-plugin mutable state. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 943a21a681..9c0b72a1e8 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -90,3 +90,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件只注册一个 command contribution,HMR 测试覆盖释放;它不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index e368cfb6a9..296290d28a 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -18,10 +18,6 @@ "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" @@ -52,7 +48,6 @@ "@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/dsh-session": "workspace:^", @@ -69,7 +64,6 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -81,7 +75,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], diff --git a/packages/client/ui-model-selection/src/invariant.ts b/packages/client/ui-model-selection/src/invariant.ts deleted file mode 100644 index 35cf7e60bd..0000000000 --- a/packages/client/ui-model-selection/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selection`. - * @module @deepseek-ai/dsh-client-ui-model-selection/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-client-ui-model-selection' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-model-selection-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a single command contribution registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json index de04dc2c92..e2d201e818 100644 --- a/packages/client/ui-model-selection/tsconfig.json +++ b/packages/client/ui-model-selection/tsconfig.json @@ -44,9 +44,6 @@ { "path": "../ui-slots" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/session" }, diff --git a/packages/client/ui-model-selection/tsdown.config.ts b/packages/client/ui-model-selection/tsdown.config.ts index c92c119458..eba18ff11c 100644 --- a/packages/client/ui-model-selection/tsdown.config.ts +++ b/packages/client/ui-model-selection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-model-selection', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-model-selection', ['lib/types/index.js']) diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index 9a4156f66a..0bc065b69d 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/packages/client/ui-permission-presets/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-permission-presets/README.md -README.md: fc7328bd0029ea86b369d3457c43aa4e24db1243 -README.zh.md: 523ea66a0f7fa3eeddb0f8fe1a901d03f9720c3d +README.md: e4a9c6b335af51e92edd99ba219d35c9bdc8f211 +README.zh.md: bd51ef230cff99d74cfa86242aeb6cb7282355c0 diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index fc7328bd00..e4a9c6b335 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/packages/client/ui-permission-presets/README.md @@ -89,3 +89,5 @@ These limits define the current permission surfaces. They are current package co None. + +**Runtime invariant:** No companion is published. The command and slot contribution lifecycles are proven by the HMR-safety spec, while the browser-only Settings controller owns no host events or cross-plugin mutable state. diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index 523ea66a0f..bd51ef230c 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -89,3 +89,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。command 与 slot contribution 的生命周期由 HMR 测试覆盖;浏览器侧 Settings controller 不持有 Host 事件或跨插件可变状态。 diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 3531416e93..6e5e602f89 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -18,10 +18,6 @@ "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" @@ -54,7 +50,6 @@ "@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-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^" @@ -71,7 +66,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", @@ -80,7 +74,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-permission-presets/src/invariant.ts b/packages/client/ui-permission-presets/src/invariant.ts deleted file mode 100644 index e4e2c201a2..0000000000 --- a/packages/client/ui-permission-presets/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-permission-presets`. - * @module @deepseek-ai/dsh-client-ui-permission-presets/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-client-ui-permission-presets' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-permission-presets-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the command and slot contribution lifecycles are - * proven by the HMR-safety spec, while the browser-only Settings controller - * owns no host events or cross-plugin mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-permission-presets/tsconfig.json b/packages/client/ui-permission-presets/tsconfig.json index bc10ea7c5a..9638be944d 100644 --- a/packages/client/ui-permission-presets/tsconfig.json +++ b/packages/client/ui-permission-presets/tsconfig.json @@ -38,9 +38,6 @@ { "path": "../../interaction/permission-presets" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/packages/client/ui-permission-presets/tsdown.config.ts b/packages/client/ui-permission-presets/tsdown.config.ts index a0fdeb1f83..3384faddba 100644 --- a/packages/client/ui-permission-presets/tsdown.config.ts +++ b/packages/client/ui-permission-presets/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-permission-presets', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-permission-presets', ['lib/types/index.js']) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 4d73fe8e72..caf47bccd3 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/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-plan/README.md -README.md: 569cac06d95c5db6af21f623c8699109e3eafaa3 -README.zh.md: a1e0465d103c2cd725909df64203eadcf3681108 +README.md: 4ff275834ec069315580c205b8fd0bf0dee9cfeb +README.zh.md: 6852822522c3f525b64eb1bb5c3899d551f53a6f diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 569cac06d9..4ff275834e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -90,3 +90,5 @@ These limits define the current plan chip. They are current package constraints, None. + +**Runtime invariant:** No companion is published. Plan state and boundary ownership are audited by dsh-plan-mode, while the control is a slot effect whose declaration, registration, and teardown are exercised by this package. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index a1e0465d10..6852822522 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -90,3 +90,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。plan state 与 boundary 归 `dsh-plan-mode` 审计;本包 control 只是由包测试覆盖声明、注册和释放的 slot effect。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 97cd4f4ca4..656d1975ef 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -18,10 +18,6 @@ "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" @@ -48,7 +44,6 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -62,7 +57,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -73,7 +67,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-plan/src/invariant.ts b/packages/client/ui-plan/src/invariant.ts deleted file mode 100644 index acea37ddeb..0000000000 --- a/packages/client/ui-plan/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plan`. - * @module @deepseek-ai/dsh-client-ui-plan/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-client-ui-plan' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-plan-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: plan state and boundary ownership are - * audited by dsh-plan-mode, while the control is a slot effect whose - * declaration, registration, and teardown are exercised by this package. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns The installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 6cfe159b48..5362b499de 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../plan/plan-mode" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-plan/tsdown.config.ts b/packages/client/ui-plan/tsdown.config.ts index 7f6a91d3f6..8f8f6ed50e 100644 --- a/packages/client/ui-plan/tsdown.config.ts +++ b/packages/client/ui-plan/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-plan', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-plan', ['lib/types/index.js']) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d672fb1544..adb83f78c6 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: c88f101eee2e6988d06ee75f564ca4aeffd5efe6 -README.zh.md: 0af43e4831fc7ca90f113705dc33d5e3411929b6 +README.md: ade3126a8d3b87a1715b13f718c312eddcb137f4 +README.zh.md: 151f01c6124c707c74b3ae252b0cc8579f433964 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c88f101eee..ade3126a8d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -118,3 +118,5 @@ These limits define how the atoms behave at the edges; they are current package None. + +**Runtime invariant:** No companion is published. Pure props-in React atoms with no Cordis API — no events, no services, no mutable cross-plugin state; rendering contracts are asserted directly by this package's component specs. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 0af43e4831..151f01c612 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -118,3 +118,5 @@ kind: "package-library" 无。 + +**运行时不变式:** 不发布伴生入口。这些是纯 props-in React atom,没有 Cordis API、事件、service 或跨插件可变状态;渲染约定由组件测试覆盖。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 8041b7dbf3..1bebff2202 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -18,10 +18,6 @@ "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" }, @@ -49,19 +45,16 @@ "shiki": "^4.3.1" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/**/*.css", "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-primitives/src/invariant.ts b/packages/client/ui-primitives/src/invariant.ts deleted file mode 100644 index 1cd4dde478..0000000000 --- a/packages/client/ui-primitives/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-primitives`. - * @module @deepseek-ai/dsh-client-ui-primitives/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-client-ui-primitives' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-primitives-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: pure props-in React atoms with no Cordis API — - * no events, no services, no mutable cross-plugin state; rendering contracts - * are asserted directly by this package's component specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-primitives/tests/invariant.client.spec.ts b/packages/client/ui-primitives/tests/invariant.client.spec.ts deleted file mode 100644 index e9fb9b0e8a..0000000000 --- a/packages/client/ui-primitives/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as PrimitivesInvariant from '@deepseek-ai/dsh-client-ui-primitives/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(PrimitivesInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index 0b6394117c..efd9866d5e 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -9,10 +9,5 @@ ], "exclude": [ "**/*.legacy.*" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 8ade010308..7f6262ae9f 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -2,5 +2,5 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( '@deepseek-ai/dsh-client-ui-primitives', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index fae26277f7..be0c5fb63d 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/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-reference/README.md -README.md: 1f82f4bb30f2ad5d194a70279f9e80885d6a048e -README.zh.md: 03a79ec4ff34431a63d8a7b0de115e3ec63b9392 +README.md: ef836af60febd0519acab61390893115c064347b +README.zh.md: 85c3a003c51a2fae1f5e678879f9eabe9b5287c6 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 1f82f4bb30..ef836af60f 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -100,3 +100,5 @@ These limits define when the reference source cannot help; they are current pack None. + +**Runtime invariant:** No companion is published. A single slash-source registration whose disposal is proven by the HMR-safety spec — it emits no cordis events and owns no cross-plugin mutable state. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 03a79ec4ff..85c3a003c5 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -100,3 +100,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件只注册一个 slash source,HMR 测试覆盖释放;它不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 70928a652c..affef26792 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -18,10 +18,6 @@ "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" @@ -53,7 +49,6 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", @@ -68,7 +63,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", @@ -76,7 +70,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-reference/src/invariant.ts b/packages/client/ui-reference/src/invariant.ts deleted file mode 100644 index 708530fb16..0000000000 --- a/packages/client/ui-reference/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-reference`. - * @module @deepseek-ai/dsh-client-ui-reference/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-client-ui-reference' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-reference-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a single slash-source registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index edaf356f28..10fa11fd83 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../context/session-reference" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../typert/protocol" }, diff --git a/packages/client/ui-reference/tsdown.config.ts b/packages/client/ui-reference/tsdown.config.ts index 1c70dc948a..f306d4db44 100644 --- a/packages/client/ui-reference/tsdown.config.ts +++ b/packages/client/ui-reference/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js']) diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 781d2b00a3..8d4289bd77 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/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-session/README.md -README.md: a98692cab4c40a53d7e4b78eec91a45af85fe76f -README.zh.md: 4b8e26da6b7255bd90af853315ad551c2ad85801 +README.md: 75fd792866afc29c6c99fd0879588e80b75b22dc +README.zh.md: bad90a7602bf097a3ba24e7a39c7cb8e5776d141 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index a98692cab4..75fd792866 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -43,3 +43,5 @@ None; Session selectors and Slot scopes do not assemble model requests. None. + +**Runtime invariant:** No companion is published. The adapter materialization path enforces Session binding consistency. diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 4b8e26da6b..bad90a7602 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -43,3 +43,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。adapter materialization 路径已经强制 Session binding 一致。 diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 7ab9428e32..24646bf009 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -18,10 +18,6 @@ "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" @@ -47,7 +43,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { @@ -56,14 +51,12 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-session/src/invariant.ts b/packages/client/ui-session/src/invariant.ts deleted file mode 100644 index 21e6363265..0000000000 --- a/packages/client/ui-session/src/invariant.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Package-owned invariant companion for the Session UI adapter. */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-session' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-session-invariant' -/** Service required before the companion reserves package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: the adapter materialization path enforces Session binding consistency. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) 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 ce6d154c0c..3eca1b7a47 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -1,5 +1,4 @@ import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import type { AgentContext, ISessions, @@ -19,7 +18,6 @@ import { UiSession, } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' -import * as SessionInvariant from '../src/invariant.ts' interface SessionsBench { readonly sessions: ISessions @@ -548,11 +546,7 @@ describe('ui-session apply', () => { expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter) }) - it('keeps the Host loader half inert and registers the invariant companion', async () => { + it('keeps the Host loader half inert', () => { expect(() => { nodeApply() }).not.toThrow() - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(SessionInvariant).await()).resolves.toBeDefined() }) }) diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json index 17caaf72e7..9d291855a6 100644 --- a/packages/client/ui-session/tsconfig.json +++ b/packages/client/ui-session/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-session/tsdown.config.ts b/packages/client/ui-session/tsdown.config.ts index 9e1e60c6ee..ca879a1232 100644 --- a/packages/client/ui-session/tsdown.config.ts +++ b/packages/client/ui-session/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 93ac13b82e..0a9d9e62a9 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-general/README.md -README.md: f0c4ab7c50798919d42ade0eac6a9a93c8a4df1c -README.zh.md: 912ea0fb16c0d1d512ff846c8eda1e724664f1ea +README.md: 0feaa51f38f8b1cd815a857a1eeb7369ef205fdd +README.zh.md: 859525ad533d9bd0abcd6d3daa0eb2cadf078229 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index f0c4ab7c50..0feaa51f38 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -105,3 +105,5 @@ These limits define what the shell itself provides versus what features must sup None. + +**Runtime invariant:** No companion is published. The settings seam validates and publishes the durable onboarding section, while slot conflicts fail loud in the slot core. The local document action is browser state over typed RPC responses and is covered by store/component tests rather than a Cordis runtime relationship. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 912ea0fb16..859525ad53 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -105,3 +105,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。settings seam 校验并发布持久 onboarding section,slot core 会拒绝冲突;本地 document action 由 store 与组件测试覆盖。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 3f4de05dd4..d5ca81b63a 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -18,10 +18,6 @@ "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" @@ -56,7 +52,6 @@ "@deepseek-ai/dsh-client-locale": "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-client-ui-renderer": "workspace:^", @@ -72,7 +67,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", @@ -82,7 +76,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts deleted file mode 100644 index 10db92524e..0000000000 --- a/packages/client/ui-settings-general/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. - * @module @deepseek-ai/dsh-client-ui-settings-general/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-client-ui-settings-general' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-general-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the settings seam validates and publishes the durable - * onboarding section, while slot conflicts fail loud in the slot core. The local - * document action is browser state over typed RPC responses and is covered by - * store/component tests rather than a Cordis runtime relationship. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tests/invariant.client.spec.ts b/packages/client/ui-settings-general/tests/invariant.client.spec.ts deleted file mode 100644 index 14784dcc1c..0000000000 --- a/packages/client/ui-settings-general/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index e13a2f4ff6..e5654a262f 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../ui-slots" }, @@ -35,9 +38,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts index bf67c4f10f..45665e4054 100644 --- a/packages/client/ui-settings-general/tsdown.config.ts +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 8b69e152a0..cc8fc78115 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: 75efb18daf3e4a96fea637f36633f216046ebccd -README.zh.md: f3c7099e242e379be5143352d4045208a8209dc6 +README.md: 412c5b6e01ca7ba023d3e434735679b24a61edc8 +README.zh.md: 47543b086ed8268b1af704e244636ab3f43b738f diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 75efb18daf..412c5b6e01 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -117,3 +117,5 @@ These limits define the editor's field coverage and the page's reach; they are c None. + +**Runtime invariant:** No companion is published. A nav-entry-only section plugin rendering a fixed empty content column — it emits no cordis events and owns no cross-plugin mutable relation. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f3c7099e24..47543b086e 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -117,3 +117,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。这是只贡献 nav entry 的 section 插件,渲染固定空 content column,不发出 Cordis 事件,也不持有跨插件可变关系。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 8c65aacc8f..9577c5e2a0 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -18,10 +18,6 @@ "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" @@ -47,7 +43,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" @@ -60,7 +55,6 @@ "@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", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", @@ -68,7 +62,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-settings-models/src/invariant.ts b/packages/client/ui-settings-models/src/invariant.ts deleted file mode 100644 index 6ead1f9e95..0000000000 --- a/packages/client/ui-settings-models/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`. - * @module @deepseek-ai/dsh-client-ui-settings-models/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-client-ui-settings-models' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-models-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a nav-entry-only section plugin rendering a fixed - * empty content column — it emits no cordis events and owns no cross-plugin - * mutable relation. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 4513bde5a2..9e30d24077 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -13,6 +13,7 @@ import { import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' +import { apply as hostApply } from '../src/index.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -60,6 +61,10 @@ function declare(slots: SlotRegistry): () => void { } describe('ui-settings-models apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares the services it uses', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', diff --git a/packages/client/ui-settings-models/tests/invariant.client.spec.ts b/packages/client/ui-settings-models/tests/invariant.client.spec.ts deleted file mode 100644 index 0d7a0578b7..0000000000 --- a/packages/client/ui-settings-models/tests/invariant.client.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { ModelsSection } from '../src/client/ModelsSection.tsx' -import type { ModelsSectionProps } from '../src/client/ModelsSection.tsx' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(ModelsInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) - - it('renders null until the shell injects the section dependencies', () => { - expect(ModelsSection({} as ModelsSectionProps)).toBeNull() - }) -}) diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index dcabd42c03..3c276ddd5f 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../locale" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../api/remotes/tsconfig.client.json" } diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts index 7a2688a097..758f6681f8 100644 --- a/packages/client/ui-settings-models/tsdown.config.ts +++ b/packages/client/ui-settings-models/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 198b22aa97..f2676c54c3 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/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-plugin-inventory/README.md -README.md: c44e150e528f20349429f9a49d416367f1ca3c41 -README.zh.md: ae2e2cd1105464f0fb262a07e96b66082a6e7166 +README.md: 09f7dc058ea6d96c8698194507470148d8de02a3 +README.zh.md: 905143f32dc3ae35889b3c9b8426975ef4ac1073 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index c44e150e52..09f7dc058e 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -97,3 +97,5 @@ These limits define the freshness and reach of the inventory view; they are curr None. + +**Runtime invariant:** No companion is published. This package owns a read-only Settings contribution. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index ae2e2cd110..905143f32d 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -97,3 +97,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包只持有一个只读 Settings contribution。 diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 1049448e57..db4681adda 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -18,10 +18,6 @@ "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" @@ -48,7 +44,6 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, @@ -59,7 +54,6 @@ "@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:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", @@ -69,7 +63,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-settings-plugin-inventory/src/invariant.ts b/packages/client/ui-settings-plugin-inventory/src/invariant.ts deleted file mode 100644 index d0ac2ec660..0000000000 --- a/packages/client/ui-settings-plugin-inventory/src/invariant.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-settings-plugin-inventory/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-client-ui-settings-plugin-inventory' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-plugin-inventory-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: this package owns a read-only Settings contribution. */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ 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 f54ee5fb6c..09852ec215 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 @@ -9,6 +9,7 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, NS } from '../src/client/index.ts' import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx' import type { PluginInventorySettingsTabInjected } from '../src/client/PluginInventorySettingsTab.tsx' +import { apply as hostApply } from '../src/index.ts' usePinnedBrowserLanguages('zh-CN') afterEach(cleanup) @@ -43,6 +44,10 @@ function declare(slots: SlotRegistry): () => void { } describe('ui-settings-plugin-inventory browser plugin', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares only the services used by the Settings Remote contribution', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) }) diff --git a/packages/client/ui-settings-plugin-inventory/tests/invariant.client.spec.ts b/packages/client/ui-settings-plugin-inventory/tests/invariant.client.spec.ts deleted file mode 100644 index e1a1e26c92..0000000000 --- a/packages/client/ui-settings-plugin-inventory/tests/invariant.client.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as PluginsInvariant from '../src/invariant.ts' - -describe('ui-settings-plugin-inventory invariant companion', () => { - it('registers the empty installer and keeps the node half inert', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined() - const { apply } = await import('../src/index.ts') - apply() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json index 3127e99d89..200683a632 100644 --- a/packages/client/ui-settings-plugin-inventory/tsconfig.json +++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-settings-plugin-inventory/tsdown.config.ts b/packages/client/ui-settings-plugin-inventory/tsdown.config.ts index 9e7c38eb90..205aaf47d4 100644 --- a/packages/client/ui-settings-plugin-inventory/tsdown.config.ts +++ b/packages/client/ui-settings-plugin-inventory/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-inventory', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-inventory', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index cd36155215..a6a1e613fd 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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-plugins/README.md -README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f -README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1 +README.md: 444b05b16b79a3a660a71984c92969d367fe22a7 +README.zh.md: c05859fc9f12ce0fe2013dc9f3549e156bd7234f diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 8453bd4cb8..444b05b16b 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -106,3 +106,5 @@ These limits define which plugins appear and how fresh the list is; they are cur None. + +**Runtime invariant:** No companion is published. This is a browser-side settings surface whose node half owns no event stream or mutable runtime data; the layering and write refusals are Host contracts covered by the owning plugins and the api-proxy. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 6a9b716b84..c05859fc9f 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -106,3 +106,5 @@ Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时 无。 + +**运行时不变式:** 不发布伴生入口。这是浏览器侧 settings surface,node half 不持有事件流或可变运行时数据;layering 与写入拒绝由所属 Host 插件和 api-proxy 覆盖。 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index c0cdbaf6b7..3e41234ac9 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -18,10 +18,6 @@ "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" @@ -51,7 +47,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { @@ -64,14 +59,12 @@ "@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", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], diff --git a/packages/client/ui-settings-plugins/src/invariant.ts b/packages/client/ui-settings-plugins/src/invariant.ts deleted file mode 100644 index 4b0eba850c..0000000000 --- a/packages/client/ui-settings-plugins/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-plugins`. - * @module @deepseek-ai/dsh-client-ui-settings-plugins/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-client-ui-settings-plugins' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-plugins-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this is a browser-side settings surface whose node half owns no event - * stream or mutable runtime data; the layering and write refusals are Host contracts covered by - * the owning plugins and the api-proxy. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 6bc979abc6..39c6d40cb3 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -12,6 +12,7 @@ import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' +import { apply as hostApply } from '../src/index.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -65,6 +66,10 @@ function declareRoot(slots: SlotRegistry): () => void { } describe('ui-settings-plugins apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares the services it uses', () => { expect(inject).toEqual([ 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', diff --git a/packages/client/ui-settings-plugins/tests/invariant.client.spec.ts b/packages/client/ui-settings-plugins/tests/invariant.client.spec.ts deleted file mode 100644 index 0bfad7b438..0000000000 --- a/packages/client/ui-settings-plugins/tests/invariant.client.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** The package's node half: an empty host body and an explained empty invariant companion. */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-settings-plugins/invariant' - -describe('invariant companion', () => { - it('reserves package ownership with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - - await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined() - }) - - it('has an empty node half', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-plugins') - - // The host body exists only so the plugin appears in the host cordis.yml; - // every surface this package ships lives in the browser half. - apply() - - expect(typeof apply).toBe('function') - }) -}) diff --git a/packages/client/ui-settings-plugins/tsconfig.json b/packages/client/ui-settings-plugins/tsconfig.json index 76fdfcc4f2..84ae2998fd 100644 --- a/packages/client/ui-settings-plugins/tsconfig.json +++ b/packages/client/ui-settings-plugins/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-settings-plugins/tsdown.config.ts b/packages/client/ui-settings-plugins/tsdown.config.ts index 5ffde161b3..f9bc4e134b 100644 --- a/packages/client/ui-settings-plugins/tsdown.config.ts +++ b/packages/client/ui-settings-plugins/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugins', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugins', ['lib/types/index.js']) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 9a230d0026..ef6c44d699 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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/README.md -README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 -README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 +README.md: 7d5417b418497f9453458336dc464472755f54a0 +README.zh.md: 550d82a31760b0141c9d7f6e925a572261aaf710 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 3e4970bff9..7d5417b418 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -105,3 +105,5 @@ These limits define where the settings transport cannot reach; they are current None. + +**Runtime invariant:** No companion is published. A presentation shell projecting the settings.section ledger into navigation — it emits no cordis events and owns no cross-plugin mutable relation; slot declaration/registration conflicts already fail loud in the slot core at load time. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index a527dfe21a..550d82a317 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -105,3 +105,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包只把 `settings.section` ledger 投影为导航,不发出 Cordis 事件,也不持有跨插件可变关系;slot core 会在加载时拒绝冲突。 diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 6c5bdc2be3..6ea1ac80eb 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -18,10 +18,6 @@ "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" @@ -50,7 +46,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { @@ -59,7 +54,6 @@ "@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:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", @@ -67,7 +61,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts deleted file mode 100644 index f78b25e91c..0000000000 --- a/packages/client/ui-settings/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings`. - * @module @deepseek-ai/dsh-client-ui-settings/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-client-ui-settings' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a presentation shell projecting the settings.section - * ledger into navigation — it emits no cordis events and owns no cross-plugin - * mutable relation; slot declaration/registration conflicts already fail loud - * in the slot core at load time. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings/tests/invariant.client.spec.ts b/packages/client/ui-settings/tests/invariant.client.spec.ts deleted file mode 100644 index 1dff6abb57..0000000000 --- a/packages/client/ui-settings/tests/invariant.client.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(SettingsInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index a5a7a85053..04fc8661bd 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -4,6 +4,7 @@ import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '../src/client/index.ts' import { SettingsSchemaService } from '../src/client/schema.ts' import { SettingsScopeBinder } from '../src/client/settings-scope.ts' +import { apply as hostApply } from '../src/index.ts' function bench() { const describeCall = vi.fn().mockResolvedValue({ @@ -16,6 +17,10 @@ function bench() { } describe('settings domain base plugin', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('mounts the scope service under settingsScope and reads once eagerly', async () => { const { ctx, describeCall, fiber } = bench() await fiber.await() diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 29fcccb57a..e6c647926b 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts index ba06fdc7c9..53ee2302d2 100644 --- a/packages/client/ui-settings/tsdown.config.ts +++ b/packages/client/ui-settings/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index d65e6ac8f6..046cb88b89 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: 64f75a46a7e372fcf96cdd0594ef694ee2c59bce -README.zh.md: 16708765480bdb5c4190a67e85bc7f5aad48fad0 +README.md: 1d7e66f792432ebf3c2093facab65cb2017ed10f +README.zh.md: ca781bf3f8db578f0c238f3534871af147e29ec2 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 64f75a46a7..1d7e66f792 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -99,3 +99,5 @@ These limits define what the shell owns versus what its occupants own; they are None. + +**Runtime invariant:** No companion is published. A pure-consumer plugin deriving its rows in-component from the standard useSessions delivery — it emits no cordis events and owns no cross-plugin mutable state; derivation and interaction behavior are asserted directly by this package's tree/component specs. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 1670876548..ca781bf3f8 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -99,3 +99,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。这是从标准 `useSessions` delivery 直接派生行的纯消费插件,不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 783a743df5..99ec472946 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -18,10 +18,6 @@ "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" @@ -56,7 +52,6 @@ "@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:^" }, @@ -70,14 +65,12 @@ "@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:^", "react": "^18.2.0" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-sidebar/src/invariant.ts b/packages/client/ui-sidebar/src/invariant.ts deleted file mode 100644 index 94e26021a8..0000000000 --- a/packages/client/ui-sidebar/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-sidebar`. - * @module @deepseek-ai/dsh-client-ui-sidebar/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-client-ui-sidebar' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-sidebar-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a pure-consumer plugin deriving its rows in-component - * from the standard useSessions delivery — it emits no cordis events and owns - * no cross-plugin mutable state; derivation and interaction behavior are - * asserted directly by this package's tree/component specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index 23c2023a8c..e638e1fb9d 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -5,6 +5,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' +import { apply as hostApply } from '../src/index.ts' async function bench(declare = true) { const ctx = new Context() @@ -25,6 +26,10 @@ async function bench(declare = true) { } describe('ui-sidebar apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares only the services it uses', () => { expect(inject).toEqual(['slots', 'layout', 'uiWorkspace', 'locale']) }) diff --git a/packages/client/ui-sidebar/tests/invariant.client.spec.ts b/packages/client/ui-sidebar/tests/invariant.client.spec.ts deleted file mode 100644 index 5995d46d1a..0000000000 --- a/packages/client/ui-sidebar/tests/invariant.client.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as SidebarInvariant from '@deepseek-ai/dsh-client-ui-sidebar/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(SidebarInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-sidebar') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index 2bffe00661..ed840c93a4 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../locale" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-sidebar/tsdown.config.ts b/packages/client/ui-sidebar/tsdown.config.ts index 2d575677b7..15da8a116a 100644 --- a/packages/client/ui-sidebar/tsdown.config.ts +++ b/packages/client/ui-sidebar/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar', ['lib/types/index.js']) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index acedab1603..001cfc22ab 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: de564bd533f6cec83bb0a55e6ebfa687fa716ee9 -README.zh.md: 2408a29b83befd68785f7e7feccc588b6372a6fb +README.md: 6f5bd109a66b061db3ab5069b15bb4f101039e73 +README.zh.md: 61941ef1b5966d9155d724873b25e03d38d214e1 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index de564bd533..6f5bd109a6 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -106,3 +106,5 @@ These limits define where the reference and the row fall back to generic behavio None. + +**Runtime invariant:** No companion is published. The slash source, locale dictionaries, and keyed toolview are registry-owned registrations whose disposal is proven by the HMR-safety spec. They emit no cordis events and own no cross-plugin mutable state. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 2408a29b83..61941ef1b5 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -106,3 +106,5 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面 无。 + +**运行时不变式:** 不发布伴生入口。slash source、locale dictionary 与 keyed toolview 都由 registry 持有,HMR 测试覆盖释放;它们不发出 Cordis 事件或持有跨插件可变状态。 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d6e679509b..d96937118c 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -18,10 +18,6 @@ "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" @@ -55,7 +51,6 @@ "@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:^" }, @@ -70,7 +65,6 @@ "@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", @@ -80,7 +74,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-skill/src/invariant.ts b/packages/client/ui-skill/src/invariant.ts deleted file mode 100644 index 718a9586a1..0000000000 --- a/packages/client/ui-skill/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-skill`. - * @module @deepseek-ai/dsh-client-ui-skill/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-client-ui-skill' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-skill-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the slash source, locale dictionaries, and keyed - * toolview are registry-owned registrations whose disposal is proven by the - * HMR-safety spec. They emit no cordis events and own no cross-plugin mutable - * state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 1771d32619..f5d9ff7072 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-skill/tsdown.config.ts b/packages/client/ui-skill/tsdown.config.ts index 802d1562f3..4c5539a6b4 100644 --- a/packages/client/ui-skill/tsdown.config.ts +++ b/packages/client/ui-skill/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-skill', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-skill', ['lib/types/index.js']) diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index 755c2d8dc0..4e29bda42f 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: 7b5b55c7f640d290b569354a1d2ce1771fcbf871 -README.zh.md: 3212304227048c5334695395160378c7dfb19fb3 +README.md: 037b2de5f1ea23e103dfb9adf766fa309cccc36f +README.zh.md: 521b4be18b30cf27d7e89b33b02f2e4a2c9e144e diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index 7b5b55c7f6..037b2de5f1 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -101,3 +101,5 @@ These limits define the registry's scaling behavior and accepted type noise; the None. + +**Runtime invariant:** No companion is published. A zero-dependency pure registry core — it emits no cordis events itself (the `ui-renderer` SlotRegistry owns the event bridge and its invariants); define/register/dispose sequencing is asserted directly by this package's behavior specs. diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index 3212304227..521b4be18b 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -101,3 +101,5 @@ register 调用可以用 `store: defineStore(...)` 声明 store 席位:`init` 无。 + +**运行时不变式:** 不发布伴生入口。这是零依赖纯 registry core,本身不发出 Cordis 事件;`ui-renderer` SlotRegistry 负责事件桥及其不变式。 diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 0e317a33ee..7d8fc97575 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -18,27 +18,20 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "license": "MIT", "devDependencies": { "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-slots/src/invariant.ts b/packages/client/ui-slots/src/invariant.ts deleted file mode 100644 index a46ba4e30f..0000000000 --- a/packages/client/ui-slots/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-slots`. - * @module @deepseek-ai/dsh-client-ui-slots/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-client-ui-slots' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-slots-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a zero-dependency pure registry core — it emits no - * cordis events itself (the `ui-renderer` SlotRegistry owns the event - * bridge and its invariants); define/register/dispose sequencing is asserted - * directly by this package's behavior specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-slots/tests/invariant.client.spec.ts b/packages/client/ui-slots/tests/invariant.client.spec.ts deleted file mode 100644 index f3c4f20915..0000000000 --- a/packages/client/ui-slots/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as SlotsInvariant from '@deepseek-ai/dsh-client-ui-slots/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(SlotsInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/ui-slots/tsconfig.json b/packages/client/ui-slots/tsconfig.json index 630e7dc65f..6a6c4319d9 100644 --- a/packages/client/ui-slots/tsconfig.json +++ b/packages/client/ui-slots/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../store" + "path": "../../../vendor/cordis" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../store" } ] } diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts index 7e4a6368c6..09a4a4dc0e 100644 --- a/packages/client/ui-slots/tsdown.config.ts +++ b/packages/client/ui-slots/tsdown.config.ts @@ -2,5 +2,5 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( '@deepseek-ai/dsh-client-ui-slots', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index e7e903ef3e..dc86b80d3c 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 3c7d185123d76270844d17dcb0e070e27f33ef7d -README.zh.md: cf022bb0d121805f9b322e84fb77c6dac412fad0 +README.md: f2e06368abdf148840b78390281488d8d009e3bf +README.zh.md: 2a4e4eac47193c7043add9dcb8d74895e30a01ff diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 3c7d185123..f2e06368ab 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -114,3 +114,5 @@ These limits define what the catalog can show and what `@` references mean; they None. + +**Runtime invariant:** No companion is published. A single slash-source registration whose disposal is proven by the HMR-safety spec — it emits no cordis events and owns no cross-plugin mutable state. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index cf022bb0d1..2a4e4eac47 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -114,3 +114,5 @@ one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 par 无。 + +**运行时不变式:** 不发布伴生入口。插件只注册一个 slash source,HMR 测试覆盖释放;它不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 75afb63a17..ef3eb00f95 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -18,10 +18,6 @@ "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" @@ -50,7 +46,6 @@ "@deepseek-ai/dsh-client-locale": "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:^", @@ -67,7 +62,6 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", @@ -83,7 +77,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-subagent/src/invariant.ts b/packages/client/ui-subagent/src/invariant.ts deleted file mode 100644 index b96fefa21c..0000000000 --- a/packages/client/ui-subagent/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-subagent`. - * @module @deepseek-ai/dsh-client-ui-subagent/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-client-ui-subagent' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-subagent-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a single slash-source registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 358585ecf7..31dd9a1ee8 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -46,9 +46,6 @@ }, { "path": "../../subagent/subagent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-subagent/tsdown.config.ts b/packages/client/ui-subagent/tsdown.config.ts index 71078e15a2..159de0398f 100644 --- a/packages/client/ui-subagent/tsdown.config.ts +++ b/packages/client/ui-subagent/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js']) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 376749a4f8..fdce69f55a 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 32ec60edb39b30afdb9b54804e62302cdc58e531 -README.zh.md: ab17910ab9cec4e1ff519af0922ee27034a6fa7c +README.md: 8d635f4523fe89c9c6d711f32f8d9cf6e4cfb627 +README.zh.md: dec856fde15aae690096bdb52fc84c00c66336ab diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 32ec60edb3..8d635f4523 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -108,3 +108,5 @@ These limits define the theme extension surface and the color authority; they ar None. + +**Runtime invariant:** No companion is published. The settings scope validates and publishes the durable theme section, while the registry emits `theme/change` synchronously with its own mutations. Store/registry agreement is covered directly by this package's Host, scope, and service behavior specs. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index ab17910ab9..dec856fde1 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -108,3 +108,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。settings scope 校验并发布持久 theme section,registry 与自身变更同步发出 `theme/change`;两者关系由 Host、scope 与 service 测试覆盖。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index ee343d5a89..2e8f1cac66 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -18,10 +18,6 @@ "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" @@ -51,7 +47,6 @@ "@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:^", "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { @@ -66,14 +61,12 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/styles", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts deleted file mode 100644 index 6463b72f5f..0000000000 --- a/packages/client/ui-theme/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-theme`. - * @module @deepseek-ai/dsh-client-ui-theme/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-client-ui-theme' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-theme-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the settings scope validates and publishes the durable - * theme section, while the registry emits `theme/change` synchronously with - * its own mutations. Store/registry agreement is covered directly by this - * package's Host, scope, and service behavior specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-theme/tests/invariant.client.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts deleted file mode 100644 index 59516d9159..0000000000 --- a/packages/client/ui-theme/tests/invariant.client.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -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-ui-renderer/client' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined() - }) - - it('node-half waits for optional Host services', () => { - nodeApply(new Context()) - expect(true).toBe(true) - }) - - it('client apply provides ctx.theme over the slots/locale edges', async () => { - // The feature registers its own Appearance settings row with localized - // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) - const ctx = new Context() - new SlotRegistry(ctx) - ctx.provide('connection', { - api: { settings: { describe: () => Promise.resolve({ - rpcId: 'theme-invariant' as never, - result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } }, - }) } }, - isLoopback: true, - } as never) - // The settings row's transport and the forwarded-event port. - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin({ inject: localeInject, apply: localeApply }).await() - await ctx.plugin({ inject, apply: clientApply }).await() - expect(ctx.get('theme')).toBeInstanceOf(ThemeRuntime) - }) -}) diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index e2f9932485..9077603ef4 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../../vendor/schemastery" + }, { "path": "../locale" }, @@ -32,9 +35,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../ui-settings" } diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index bf04d20d5d..b200f25daa 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -2,5 +2,5 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( '@deepseek-ai/dsh-client-ui-theme', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 5cfd56493f..408863e32f 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: 773a93801ebc214e2d5c94d52864f5c5dd887100 -README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa +README.md: 85f385f7a3f8ebf20c5f1ca5116833fbe1ebe5f9 +README.zh.md: 3c3df158efa5c823dd866f16d35fdf98be4705ff diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 773a93801e..85f385f7a3 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -109,3 +109,5 @@ These limits define the dispatch depth and the view ownership; they are current None. + +**Runtime invariant:** No companion is published. Tool composition is browser-only and contributes no events or cross-plugin mutable state; slot ownership is checked by ui-slots. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 88df08d7b5..3c3df158ef 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -109,3 +109,5 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` 无。 + +**运行时不变式:** 不发布伴生入口。Tool composition 只存在于浏览器,不贡献事件或跨插件可变状态;slot 所有权由 ui-slots 校验。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index ca96120343..f35bdce2de 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -18,10 +18,6 @@ "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" @@ -54,7 +50,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "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:^", @@ -70,7 +65,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "react": "^18.2.0", @@ -83,7 +77,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-tool/src/invariant.ts b/packages/client/ui-tool/src/invariant.ts deleted file mode 100644 index e00671b0d7..0000000000 --- a/packages/client/ui-tool/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-tool`. - * @module @deepseek-ai/dsh-client-ui-tool/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-client-ui-tool' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-tool-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: Tool composition is browser-only and contributes no - * events or cross-plugin mutable state; slot ownership is checked by ui-slots. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 9abd3e0837..ea2e8a44e9 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -41,9 +41,6 @@ { "path": "../ui-slots" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/tools" }, diff --git a/packages/client/ui-tool/tsdown.config.ts b/packages/client/ui-tool/tsdown.config.ts index 1c66514f9a..cd27a6e92b 100644 --- a/packages/client/ui-tool/tsdown.config.ts +++ b/packages/client/ui-tool/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-tool', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-tool', ['lib/types/index.js']) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index a5e2e32ac8..813d00e15e 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: cb11a8186f32761b6af208ae79196feff8f098d5 -README.zh.md: 5b02e965b3d4e041b7a405d210bace366551cecb +README.md: 258a162130fe71433fc3128889ba69f3b622e04f +README.zh.md: da1446ab226ec6aaa5654b295463c8b3d97868a5 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index cb11a8186f..258a162130 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -96,3 +96,5 @@ These limits define what the view can show while work is in flight; they are cur None. + +**Runtime invariant:** No companion is published. A pure-consumer plugin — it emits no cordis events and owns no mutable cross-plugin state; its view-slot registration is a plain effect whose disposal the slot ledger's own specs and this package's behavior specs observe directly. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5b02e965b3..da1446ab22 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -96,3 +96,5 @@ Trajectory 要求会话壳把 composer 作为浮层置于全高记录表上方 无。 + +**运行时不变式:** 不发布伴生入口。这是纯消费插件,不发出 Cordis 事件,也不持有跨插件可变状态;view-slot effect 的释放由 slot ledger 与包测试观察。 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 242810c753..407d456ae8 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -18,10 +18,6 @@ "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" @@ -53,7 +49,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -73,7 +68,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", @@ -91,7 +85,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-trajectory/src/invariant.ts b/packages/client/ui-trajectory/src/invariant.ts deleted file mode 100644 index 7a43d872cc..0000000000 --- a/packages/client/ui-trajectory/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-trajectory`. - * @module @deepseek-ai/dsh-client-ui-trajectory/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-client-ui-trajectory' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-trajectory-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a pure-consumer plugin — it emits no cordis events - * and owns no mutable cross-plugin state; its view-slot registration is a - * plain effect whose disposal the slot ledger's own specs and this - * package's behavior specs observe directly. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 0d0f85fe1d..8de5ec9dd9 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -44,9 +44,6 @@ { "path": "../../compaction/compaction" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../llm/llm" }, diff --git a/packages/client/ui-trajectory/tsdown.config.ts b/packages/client/ui-trajectory/tsdown.config.ts index 5fe169be39..8e613b7399 100644 --- a/packages/client/ui-trajectory/tsdown.config.ts +++ b/packages/client/ui-trajectory/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-trajectory', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-trajectory', ['lib/types/index.js']) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index cd4d3e032d..6990b1267e 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-user-questions/README.md -README.md: 2462a3d25644cf073b4652d564a9f7a213e92250 -README.zh.md: 296974703d77791393811b89fd046078969584b5 +README.md: a779515d22a85cca71fe4a20d7d44ab30f9dcea6 +README.zh.md: 13a10ff039ede6bf0fd87ba4f12313ab19d4ba73 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 2462a3d256..a779515d22 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -101,3 +101,5 @@ These limits define draft durability and composer ownership; they are current pa None. + +**Runtime invariant:** No companion is published. Tool and slot registrations are effects owned and observed by their respective registries; the host pending table is exercised through the public wire protocol. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 296974703d..13a10ff039 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -101,3 +101,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。tool 与 slot 注册都是由各自 registry 持有和观察的 effect;Host pending table 通过公开 wire protocol 测试。 diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index b8059eecfb..0f5dc041b4 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -18,10 +18,6 @@ "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" @@ -55,7 +51,6 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", @@ -70,7 +65,6 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", @@ -87,7 +81,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-user-questions/src/invariant.ts b/packages/client/ui-user-questions/src/invariant.ts deleted file mode 100644 index 9a91ad69af..0000000000 --- a/packages/client/ui-user-questions/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-user-questions`. - * @module @deepseek-ai/dsh-client-ui-user-questions/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-client-ui-user-questions' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-user-questions-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: tool and slot registrations are effects - * owned and observed by their respective registries; the host pending table is - * exercised through the public wire protocol. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns The installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-user-questions/tsconfig.json b/packages/client/ui-user-questions/tsconfig.json index 5a4fc42f8b..e66c67d105 100644 --- a/packages/client/ui-user-questions/tsconfig.json +++ b/packages/client/ui-user-questions/tsconfig.json @@ -46,9 +46,6 @@ }, { "path": "../ui-session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-user-questions/tsdown.config.ts b/packages/client/ui-user-questions/tsdown.config.ts index 85744351de..a60da00031 100644 --- a/packages/client/ui-user-questions/tsdown.config.ts +++ b/packages/client/ui-user-questions/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-user-questions', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-user-questions', ['lib/types/index.js']) diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml index 3df2ccbc96..24ddf69ede 100644 --- a/packages/client/ui-workflow-run/README.i18n.yaml +++ b/packages/client/ui-workflow-run/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-workflow-run/README.md -README.md: 1cf482bb8f9d0f8c41e82a639fa6cb656f5fd563 -README.zh.md: 2b559c666b1ea00374717b9f401ea02113459ac6 +README.md: 1919b387f87c8d0af112f59415e6c821db5adab0 +README.zh.md: b87a4f956713c947a49b92b6b50e119e6f073bb8 diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md index 1cf482bb8f..1919b387f8 100644 --- a/packages/client/ui-workflow-run/README.md +++ b/packages/client/ui-workflow-run/README.md @@ -98,3 +98,5 @@ These limits define which runs produce records and what the node exposes; they a None. + +**Runtime invariant:** No companion is published. The browser plugin contributes one effect-owned Conversation Definition, keyed renderer, and dictionary; tests prove their disposal and the Host tool package owns the durable event invariant. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md index 2b559c666b..b87a4f9567 100644 --- a/packages/client/ui-workflow-run/README.zh.md +++ b/packages/client/ui-workflow-run/README.zh.md @@ -98,3 +98,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。浏览器插件只贡献 effect 所有的 Conversation Definition、keyed renderer 与 dictionary;Host tool 包负责持久事件不变式。 diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index bc5273310b..51b210dc05 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -18,10 +18,6 @@ "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" @@ -48,7 +44,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], @@ -60,7 +55,6 @@ "@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:^", "@deepseek-ai/dsh-workflow": "workspace:^", @@ -77,7 +71,6 @@ "@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:^", "@deepseek-ai/dsh-workflow": "workspace:^", diff --git a/packages/client/ui-workflow-run/src/invariant.ts b/packages/client/ui-workflow-run/src/invariant.ts deleted file mode 100644 index 7d7da9b84d..0000000000 --- a/packages/client/ui-workflow-run/src/invariant.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Package-owned invariant companion for the workflow-run UI plugin. */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-workflow-run-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the browser plugin contributes one effect-owned - * Conversation Definition, keyed renderer, and dictionary; tests prove their - * disposal and the Host tool package owns the durable event invariant. - */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ 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 1758c8c27e..c7a6c258ab 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 @@ -29,7 +29,6 @@ import { workflowRunDefinition, type WorkflowRunChatData, } from '../src/client/workflow-definition.ts' import { apply as applyNode } from '../src/index.ts' -import { apply as applyInvariant } from '../src/invariant.ts' import type {} from '../src/client/index.ts' afterEach(cleanup) @@ -906,15 +905,7 @@ describe('plugin lifecycle', () => { await replacement.dispose() }) - it('keeps the node half inert and registers invariant ownership', async () => { + it('keeps the node half inert', () => { applyNode() - const registered: string[] = [] - const ctx = new Context() - ctx.provide('invariants') - ctx.set('invariants', { - register: (pkg: string) => { registered.push(pkg); return () => {} }, - } as never) - await applyInvariant(ctx) - expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run']) }) }) diff --git a/packages/client/ui-workflow-run/tsconfig.json b/packages/client/ui-workflow-run/tsconfig.json index 643952f137..05f4f4845c 100644 --- a/packages/client/ui-workflow-run/tsconfig.json +++ b/packages/client/ui-workflow-run/tsconfig.json @@ -46,9 +46,6 @@ }, { "path": "../../workflow/tool-workflow" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-workflow-run/tsdown.config.ts b/packages/client/ui-workflow-run/tsdown.config.ts index c6cfded6a2..87d4cd34c1 100644 --- a/packages/client/ui-workflow-run/tsdown.config.ts +++ b/packages/client/ui-workflow-run/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js']) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 8b634a4342..98ec8dac88 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: be13acdfc3fe1241266c731fefcfea5565691bb4 -README.zh.md: 2c765183a547a6cde410acd5366b8145f9000ae9 +README.md: e93cd04a67d3acd10b5d214b9d266c07764d0a5d +README.zh.md: 04d92e1c1bdab187c9188359e2d717545bff39d6 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index be13acdfc3..e93cd04a67 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -111,3 +111,5 @@ These limits define the search depth, the archive surface, and the picking carri None. + +**Runtime invariant:** No companion is published. A pure-consumer plugin registering presentational components into two host-declared slots plus its locale dictionaries — its inject face is stateless RPC wrappers plus a create-and-open call; it emits no cordis events and owns no cross-plugin mutable state. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 2c765183a5..04d92e1c1b 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -111,3 +111,5 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor 无。 + +**运行时不变式:** 不发布伴生入口。这是纯消费插件,只注册展示组件和 locale dictionary;inject face 是无状态 RPC wrapper 加 create-and-open 调用,不发出事件或持有跨插件可变状态。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 6992a883c1..c87a655e25 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -18,10 +18,6 @@ "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" @@ -63,7 +59,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -83,7 +78,6 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", @@ -93,7 +87,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/client/ui-workspace/src/invariant.ts b/packages/client/ui-workspace/src/invariant.ts deleted file mode 100644 index 4a15d37998..0000000000 --- a/packages/client/ui-workspace/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-workspace`. - * @module @deepseek-ai/dsh-client-ui-workspace/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-workspace-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a pure-consumer plugin registering presentational - * components into two host-declared slots plus its locale dictionaries — its - * inject face is stateless RPC wrappers plus a create-and-open call; it - * emits no cordis events and owns no cross-plugin mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 941c251f56..d1e2c3309f 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -7,6 +7,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' +import { apply as hostApply } from '../src/index.ts' async function bench() { const ctx = new Context() @@ -86,6 +87,10 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { } describe('ui-workspace apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + it('declares the services it drives', () => { expect(inject).toEqual([ 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', diff --git a/packages/client/ui-workspace/tests/invariant.client.spec.ts b/packages/client/ui-workspace/tests/invariant.client.spec.ts deleted file mode 100644 index 6d9d13ea09..0000000000 --- a/packages/client/ui-workspace/tests/invariant.client.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(WorkspaceInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-workspace') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 398f9c2128..425684141e 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../ui-session" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/workspace-path" } diff --git a/packages/client/ui-workspace/tsdown.config.ts b/packages/client/ui-workspace/tsdown.config.ts index 084fe49266..c4bb255e57 100644 --- a/packages/client/ui-workspace/tsdown.config.ts +++ b/packages/client/ui-workspace/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-workspace', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-workspace', ['lib/types/index.js']) diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 96037ccb77..8398e2360c 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/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/web/README.md -README.md: c505698413b29aebe5c91d05311305c36bd7949a -README.zh.md: 176679338c0e824afe917b834293ceea0fb392a3 +README.md: e99c83af3a0226ff4091509c300bd8e7d085a25a +README.zh.md: 9a15ca0df65ac7fffe78f5fed49dd08281260b2b diff --git a/packages/client/web/README.md b/packages/client/web/README.md index c505698413..e99c83af3a 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -121,3 +121,5 @@ These limits define what the boot kernel does not support. They are current pack None. + +**Runtime invariant:** No companion is published. The vite entry shell — boot glue and module-table seeding with no cordis events and no cross-plugin mutable state; the boot chain (loading page → settled → one-flip UI) is asserted by the web smoke e2e against the real carrier. diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index 176679338c..9a15ca0df6 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -121,3 +121,5 @@ kind: "package-library" 无。 + +**运行时不变式:** 不发布伴生入口。这是 Vite entry shell,只负责 boot glue 与 module-table seeding,不发出 Cordis 事件或持有跨插件可变状态;boot chain 由真实 carrier 的 web smoke e2e 覆盖。 diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 6d663b1056..72ac4b7a86 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -18,10 +18,6 @@ "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" }, @@ -33,7 +29,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "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:^", @@ -42,12 +37,10 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/**/*.css", "lib/types/**/*.d.ts" ] diff --git a/packages/client/web/src/invariant.ts b/packages/client/web/src/invariant.ts deleted file mode 100644 index 8964082cc8..0000000000 --- a/packages/client/web/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-web`. - * @module @deepseek-ai/dsh-client-web/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-client-web' - -/** Cordis companion plugin name. */ -export const name = 'client-web-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the vite entry shell — boot glue and module-table - * seeding with no cordis events and no cross-plugin mutable state; the boot - * chain (loading page → settled → one-flip UI) is asserted by the web smoke - * e2e against the real carrier. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json index 5ee451203d..d0a5a37049 100644 --- a/packages/client/web/tsconfig.json +++ b/packages/client/web/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../ui-renderer" - }, - { - "path": "../../runtime-diagnostics/invariants" } ], "exclude": [ diff --git a/packages/client/web/tsdown.config.ts b/packages/client/web/tsdown.config.ts index 4d522e41db..7f3398c4f7 100644 --- a/packages/client/web/tsdown.config.ts +++ b/packages/client/web/tsdown.config.ts @@ -2,5 +2,5 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( '@deepseek-ai/dsh-client-web', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 535aee0b2d..8d4f5dc8ad 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/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/code-runtime/code-runtime-python/README.md -README.md: 7aea5ee4c031a36718e66a583762f61832596d04 -README.zh.md: 778cc61ef28be616b8b0ed1ce8f0c9396143a768 +README.md: b6016837319200c55766a03f279ae3acf441d19f +README.zh.md: 0ba03ecbcb6924ccbfbfcede52d26dcea698187d diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 7aea5ee4c0..b601683731 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -73,7 +73,7 @@ Completion values and binding arguments cross as exact JSON: values serialize wi | [`src/protocol.ts`](src/protocol.ts) | Host side: frame codec, hostile-frame validators, lossless-JSON meters, shared marker text | | [`py/protocol.py`](py/protocol.py) | Python side: `PROTOCOL_FD`, `TypedDict` frame mirrors, `log_truncation_marker` | | [`tests/protocol-mirror.e2e.ts`](tests/protocol-mirror.e2e.ts) | Cross-language mirror test against a real `python3` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the package registers no mutable data relation) | +| — | No runtime invariant companion is published; this package ships only the fd-3 wire-protocol codec and its Python mirror, exposing no runtime event sequence or mutable data relation; `protocol.spec.ts` and `protocol-mirror.e2e.ts` cover the protocol's behavior. | diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 778cc61ef2..0ba03ecbcb 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -73,7 +73,7 @@ host 侧校验会静默丢弃垃圾,因此格式错误或伪造的帧绝不会 | [`src/protocol.ts`](src/protocol.ts) | host 侧:帧编解码、敌意帧校验器、无损 JSON 计量器、共享标记文本 | | [`py/protocol.py`](py/protocol.py) | Python 侧:`PROTOCOL_FD`、`TypedDict` 帧镜像、`log_truncation_marker` | | [`tests/protocol-mirror.e2e.ts`](tests/protocol-mirror.e2e.ts) | 对照真实 `python3` 的跨语言镜像测试 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;本包不注册任何可变数据关系) | +| — | 不发布运行时不变式伴生入口;本包不注册任何可变数据关系。 | diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 9f49a052eb..72a33530b4 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -18,25 +18,18 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "py/**/*.py", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/code-runtime/code-runtime-python/src/invariant.ts b/packages/code-runtime/code-runtime-python/src/invariant.ts deleted file mode 100644 index 6f616bc5c0..0000000000 --- a/packages/code-runtime/code-runtime-python/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-python`. - * @module @deepseek-ai/dsh-code-runtime-python/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-code-runtime-python' - -/** Cordis companion plugin name. */ -export const name = 'code-runtime-python-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package ships only the fd-3 wire-protocol codec and its Python mirror, - * exposing no runtime event sequence or mutable data relation; `protocol.spec.ts` and - * `protocol-mirror.e2e.ts` cover the protocol's behavior. - */ -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/code-runtime/code-runtime-python/tsconfig.json b/packages/code-runtime/code-runtime-python/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/code-runtime/code-runtime-python/tsconfig.json +++ b/packages/code-runtime/code-runtime-python/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/code-runtime/code-runtime-python/tsdown.config.ts b/packages/code-runtime/code-runtime-python/tsdown.config.ts index df5bdeae1e..015cbf7a52 100644 --- a/packages/code-runtime/code-runtime-python/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-python/tsdown.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsdown' * under `py/` (whitelisted in package.json `files`) — no build step needed. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/code-runtime/code-runtime-worker-thread/README.i18n.yaml b/packages/code-runtime/code-runtime-worker-thread/README.i18n.yaml index 9608351798..fe009e77a6 100644 --- a/packages/code-runtime/code-runtime-worker-thread/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-worker-thread/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/code-runtime/code-runtime-worker-thread/README.md -README.md: 683a774b00a2305b0489a62b1b33f9d20dddffe8 -README.zh.md: 34b828081ebbde7fea70f4dd409e2980c6942eea +README.md: 38c977ca3b387b4d8e5e9fb998a202a21a6d2415 +README.zh.md: 30825aa81a268ea0c61021e98eb0cb8237a3a756 diff --git a/packages/code-runtime/code-runtime-worker-thread/README.md b/packages/code-runtime/code-runtime-worker-thread/README.md index 683a774b00..38c977ca3b 100644 --- a/packages/code-runtime/code-runtime-worker-thread/README.md +++ b/packages/code-runtime/code-runtime-worker-thread/README.md @@ -100,7 +100,7 @@ Two independent budgets exist because the peer is hostile: `computeMs` meters th | [`src/protocol.ts`](src/protocol.ts) | Port message vocabulary between host and worker | | [`src/worker-json.ts`](src/worker-json.ts) | Worker-side lossless-JSON encode/decode | | [`src/output-json.ts`](src/output-json.ts) | Byte metering and truncation for the outer ledger | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; see its reason) | +| — | No runtime invariant companion is published; this process-boundary implementation exposes no same-process event relation; worker protocol and built-worker tests cover it. | ### The worker entry, unbuilt and built diff --git a/packages/code-runtime/code-runtime-worker-thread/README.zh.md b/packages/code-runtime/code-runtime-worker-thread/README.zh.md index 34b828081e..30825aa81a 100644 --- a/packages/code-runtime/code-runtime-worker-thread/README.zh.md +++ b/packages/code-runtime/code-runtime-worker-thread/README.zh.md @@ -100,7 +100,7 @@ kind: "package-reference" | [`src/protocol.ts`](src/protocol.ts) | host 与 worker 之间的端口消息词汇 | | [`src/worker-json.ts`](src/worker-json.ts) | worker 侧无损 JSON 编解码 | | [`src/output-json.ts`](src/output-json.ts) | 外层账本的字节计量与截断 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;理由见其说明) | +| — | 不发布运行时不变式伴生入口;本进程边界实现不暴露可在同一进程内对照的事件关系,worker 协议测试与构建后 worker 测试负责覆盖。 | ### 未构建与已构建的 worker 入口 diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 7b4b024c71..1093459090 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./worker": { "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" @@ -30,14 +26,12 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -47,7 +41,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/code-runtime/code-runtime-worker-thread/src/invariant.ts b/packages/code-runtime/code-runtime-worker-thread/src/invariant.ts deleted file mode 100644 index 5b0d56c964..0000000000 --- a/packages/code-runtime/code-runtime-worker-thread/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-worker-thread`. - * @module @deepseek-ai/dsh-code-runtime-worker-thread/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-code-runtime-worker-thread' - -/** Cordis companion plugin name. */ -export const name = 'code-runtime-worker-thread-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this process-boundary implementation exposes no same-process event relation; - * worker protocol and built-worker tests cover it. - */ -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/code-runtime/code-runtime-worker-thread/tsconfig.json b/packages/code-runtime/code-runtime-worker-thread/tsconfig.json index 9d6b3a8cd1..2bbaf44da7 100644 --- a/packages/code-runtime/code-runtime-worker-thread/tsconfig.json +++ b/packages/code-runtime/code-runtime-worker-thread/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../code-runtime" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/timeout" } diff --git a/packages/code-runtime/code-runtime-worker-thread/tsdown.config.ts b/packages/code-runtime/code-runtime-worker-thread/tsdown.config.ts index 1c40637722..6fee724195 100644 --- a/packages/code-runtime/code-runtime-worker-thread/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker-thread/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js', 'lib/types/invariant.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index 54afe6235e..684c3ff4a5 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-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/code-runtime/code-runtime/README.md -README.md: e3d43e7add4992c44fef966651f91addb81aa1eb -README.zh.md: bcbeaa8bbdfee33baa1b29e232038e2bd6b77728 +README.md: 137a0d5e0212da7680e22539a1d9233f898a7fe2 +README.zh.md: 686d1885c7da585eb54f74260980b2d5bd3d46f6 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index e3d43e7add..137a0d5e02 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -85,7 +85,7 @@ Binding-global and error-class names are language-portable: they must match the |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `CodeRuntime` service and the portable-identifier exclusion sets | | [`src/types.ts`](src/types.ts) | Vocabulary: `CodeRunRequest`, `CodeBindingNamespace`, `CodeJsonValue`, `CodeRunResult`, `CodeRunFailure` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam registers no mutable data relation) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index bcbeaa8bbd..686d1885c7 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -85,7 +85,7 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配标识 |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `CodeRuntime` 服务与可移植标识符排除集 | | [`src/types.ts`](src/types.ts) | 词汇:`CodeRunRequest`、`CodeBindingNamespace`、`CodeJsonValue`、`CodeRunResult`、`CodeRunFailure` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;seam 不注册任何可变数据关系) | +| — | 不发布运行时不变式伴生入口;seam 不注册任何可变数据关系。 | diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index fda9c1a984..59fe7f45b3 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts deleted file mode 100644 index 5f234691e0..0000000000 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime`. - * @module @deepseek-ai/dsh-code-runtime/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-code-runtime' - -/** Cordis companion plugin name. */ -export const name = 'code-runtime-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/code-runtime/code-runtime/tsconfig.json b/packages/code-runtime/code-runtime/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/code-runtime/code-runtime/tsconfig.json +++ b/packages/code-runtime/code-runtime/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/compaction/command-compact/README.i18n.yaml b/packages/compaction/command-compact/README.i18n.yaml index 583ec35ea0..d453cd077a 100644 --- a/packages/compaction/command-compact/README.i18n.yaml +++ b/packages/compaction/command-compact/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/compaction/command-compact/README.md -README.md: 15076f71cf61847ec1059e9d75fbd6506ccd7bd6 -README.zh.md: 43d9f4f67048e27ae741e0a867a9e45bbf3d30e6 +README.md: b8881234a3892ac566e41f28fbccbbf9ffa7fc3d +README.zh.md: 890c8f6ab45532b1305d3433ad3ebf3da69e7dc8 diff --git a/packages/compaction/command-compact/README.md b/packages/compaction/command-compact/README.md index 15076f71cf..b8881234a3 100644 --- a/packages/compaction/command-compact/README.md +++ b/packages/compaction/command-compact/README.md @@ -95,7 +95,7 @@ Every resolved invocation records the executor-owned log-only pair `command/run` | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `/compact` registration, argument rejection, error-code mapping, lifecycle drain | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the compaction seam and command registry own the durable contracts) | +| — | No runtime invariant companion is published; this command adapter owns no state or event stream; the compaction seam owns the balanced durable transaction and the command registry owns registration and dispatch lifecycle. | diff --git a/packages/compaction/command-compact/README.zh.md b/packages/compaction/command-compact/README.zh.md index 43d9f4f670..890c8f6ab4 100644 --- a/packages/compaction/command-compact/README.zh.md +++ b/packages/compaction/command-compact/README.zh.md @@ -95,7 +95,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`/compact` 注册、参数拒绝、错误码映射、生命周期排空 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;压缩 seam 与命令注册表拥有持久约定) | +| — | 不发布运行时不变式伴生入口;压缩 seam 与命令注册表拥有持久约定。 | diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index f5a7edf1f3..cf2aecce39 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -18,23 +18,17 @@ "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-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -43,7 +37,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/compaction/command-compact/src/invariant.ts b/packages/compaction/command-compact/src/invariant.ts deleted file mode 100644 index 903c9d4375..0000000000 --- a/packages/compaction/command-compact/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-command-compact`. - * @module @deepseek-ai/dsh-command-compact/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-command-compact' - -/** Cordis companion plugin name. */ -export const name = 'command-compact-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this command adapter owns no state or event stream; the compaction seam owns - * the balanced durable transaction and the command registry owns registration and dispatch lifecycle. - */ -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/compaction/command-compact/tests/invariant.spec.ts b/packages/compaction/command-compact/tests/invariant.spec.ts deleted file mode 100644 index c5aa4dc3dc..0000000000 --- a/packages/compaction/command-compact/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import * as invariant from '@deepseek-ai/dsh-command-compact/invariant' - -describe('command-compact invariant companion', () => { - it('registers the package-owned no-op installer', async () => { - const register = vi.fn().mockReturnValue(() => {}) - const ctx = { invariants: { register } } as never - const dispose = await invariant.apply(ctx) - expect(invariant.name).toBe('command-compact-invariant') - expect(invariant.inject).toEqual(['invariants']) - expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-command-compact', expect.any(Function)) - expect(() => { - const install = register.mock.calls[0]![1] as () => void - install() - }).not.toThrow() - expect(dispose).toBeTypeOf('function') - }) -}) diff --git a/packages/compaction/command-compact/tsconfig.json b/packages/compaction/command-compact/tsconfig.json index dfb54c7893..8ab8c37cc4 100644 --- a/packages/compaction/command-compact/tsconfig.json +++ b/packages/compaction/command-compact/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../compaction" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml index 9801eba71a..001d9d1a0a 100644 --- a/packages/compaction/compaction-basic/README.i18n.yaml +++ b/packages/compaction/compaction-basic/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/compaction/compaction-basic/README.md -README.md: 60b6bb7fb3e4d7a36f0e609e64238ea1020def4d -README.zh.md: e9f081c39720999f5ba537780a91582c41268fba +README.md: 026a54a3cc0d2bf45873acf4f607eb7b392e5dbf +README.zh.md: 60534f6ef1b065a0c19642c7d02315f66f2b19ca diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md index 60b6bb7fb3..026a54a3cc 100644 --- a/packages/compaction/compaction-basic/README.md +++ b/packages/compaction/compaction-basic/README.md @@ -134,7 +134,7 @@ The transaction validates the surface span and the durable lock, appends `compac | [`src/summarizer.ts`](src/summarizer.ts) | Default `ctx.llm.stream()` summarization, checkpoint framing, safe-summary projection | | [`src/config.ts`](src/config.ts) | Load-time validation and routed-model policy resolution | | [`src/types.ts`](src/types.ts) | `BasicCompactionConfig` and resolved policy vocabulary | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the durable bracket is observable in the session log) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md index e9f081c397..60534f6ef1 100644 --- a/packages/compaction/compaction-basic/README.zh.md +++ b/packages/compaction/compaction-basic/README.zh.md @@ -134,7 +134,7 @@ kind: "package-reference" | [`src/summarizer.ts`](src/summarizer.ts) | 默认 `ctx.llm.stream()` 摘要、检查点框定、安全摘要投影 | | [`src/config.ts`](src/config.ts) | 加载时验证与路由模型策略解析 | | [`src/types.ts`](src/types.ts) | `BasicCompactionConfig` 与已解析策略词汇 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;持久标记对可在会话日志中观察) | +| — | 不发布运行时不变式伴生入口;持久标记对可在会话日志中观察。 | diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 6f16c84c47..125aef76d1 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -18,16 +18,11 @@ "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", @@ -35,7 +30,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", diff --git a/packages/compaction/compaction-basic/src/invariant.ts b/packages/compaction/compaction-basic/src/invariant.ts deleted file mode 100644 index fdc9662fe8..0000000000 --- a/packages/compaction/compaction-basic/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-compaction-basic`. - * @module @deepseek-ai/dsh-compaction-basic/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-compaction-basic' - -/** Cordis companion plugin name. */ -export const name = 'compaction-basic-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index a0497f8041..be65ba8e1e 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -8,7 +8,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as CompactionInvariant from '@deepseek-ai/dsh-compaction/invariant' -import * as CompactionBasicInvariant from '@deepseek-ai/dsh-compaction-basic/invariant' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compaction' import type { CompactionResult } from '@deepseek-ai/dsh-compaction' @@ -105,7 +104,6 @@ async function loopHarness(): Promise { await ctx.plugin(AgentInvariant) await ctx.plugin(AgentLoopInvariant) await ctx.plugin(CompactionInvariant) - await ctx.plugin(CompactionBasicInvariant) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) diff --git a/packages/compaction/compaction-basic/tsconfig.json b/packages/compaction/compaction-basic/tsconfig.json index 7b994a2169..8c3bbf0d20 100644 --- a/packages/compaction/compaction-basic/tsconfig.json +++ b/packages/compaction/compaction-basic/tsconfig.json @@ -33,9 +33,6 @@ { "path": "../compaction" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../compaction-tool-result-pruner" } diff --git a/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml b/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml index 7c87bf1036..404c1ed713 100644 --- a/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml +++ b/packages/compaction/compaction-tool-result-pruner/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/compaction/compaction-tool-result-pruner/README.md -README.md: 7504d76721d1ca600ac2cad3a280b77819d278e1 -README.zh.md: 5b957397aae22a358f29b529632bf7e32dca3414 +README.md: ae6f0038b14adc7f50618a0d3d67373a05768449 +README.zh.md: c8c3654b8a6540cb9c9124b4310628ade09dcb36 diff --git a/packages/compaction/compaction-tool-result-pruner/README.md b/packages/compaction/compaction-tool-result-pruner/README.md index 7504d76721..ae6f0038b1 100644 --- a/packages/compaction/compaction-tool-result-pruner/README.md +++ b/packages/compaction/compaction-tool-result-pruner/README.md @@ -88,7 +88,7 @@ Pruning measures `text` blocks by Unicode code point (non-text blocks cost zero) | [`src/index.ts`](src/index.ts) | Plugin entry: `ToolResultPruner` service, `pruneSession` / `pruneContent` / `measureContent` | | [`src/config.ts`](src/config.ts) | `PRUNE_MARKER`, defaults, code-point counting, budget validation | | [`src/types.ts`](src/types.ts) | `ToolResultPruneConfig`, `ResolvedConfig`, `PrunedEntry`, `PruneResult` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; replacements are observable in the session log) | +| — | No runtime invariant companion is published; Session validates each content-only rewrite and its companion owns cross-event enclosure. | diff --git a/packages/compaction/compaction-tool-result-pruner/README.zh.md b/packages/compaction/compaction-tool-result-pruner/README.zh.md index 5b957397aa..c8c3654b8a 100644 --- a/packages/compaction/compaction-tool-result-pruner/README.zh.md +++ b/packages/compaction/compaction-tool-result-pruner/README.zh.md @@ -88,7 +88,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`ToolResultPruner` 服务、`pruneSession` / `pruneContent` / `measureContent` | | [`src/config.ts`](src/config.ts) | `PRUNE_MARKER`、默认值、码点计数、预算验证 | | [`src/types.ts`](src/types.ts) | `ToolResultPruneConfig`、`ResolvedConfig`、`PrunedEntry`、`PruneResult` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;替换可在会话日志中观察) | +| — | 不发布运行时不变式伴生入口;替换可在会话日志中观察。 | diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 64cd5f58cb..8e95e1b927 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -18,22 +18,16 @@ "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-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", diff --git a/packages/compaction/compaction-tool-result-pruner/src/invariant.ts b/packages/compaction/compaction-tool-result-pruner/src/invariant.ts deleted file mode 100644 index 469c90a6cd..0000000000 --- a/packages/compaction/compaction-tool-result-pruner/src/invariant.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-compaction-tool-result-pruner`. - * @module @deepseek-ai/dsh-compaction-tool-result-pruner/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-compaction-tool-result-pruner' - -/** Cordis companion plugin name. */ -export const name = 'compaction-tool-result-pruner-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** No runtime invariant: Session validates each content-only rewrite and its companion owns cross-event enclosure. */ -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/compaction/compaction-tool-result-pruner/tsconfig.json b/packages/compaction/compaction-tool-result-pruner/tsconfig.json index 82cd02d412..b674827ed8 100644 --- a/packages/compaction/compaction-tool-result-pruner/tsconfig.json +++ b/packages/compaction/compaction-tool-result-pruner/tsconfig.json @@ -12,7 +12,6 @@ { "path": "../../llm/llm" }, { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, - { "path": "../compaction" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../compaction" } ] } diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index 19102ec246..faab260a4e 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/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/context/agent-instructions/README.md -README.md: 798134a9e11a4e2cf6337f1f7a2d9c79410860eb -README.zh.md: d187494ec0f6a907df1907d6420f589792beff8b +README.md: 7aea2d922da587c6736cca9c4b9e5d72d096e8b6 +README.zh.md: 0fed39a33f16cd562f41778553e3acaff4e45939 diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index 798134a9e1..7aea2d922d 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -93,7 +93,7 @@ The plugin is built on one principle: workspace instructions are durable convers | [`src/render.ts`](src/render.ts) | Instruction rendering, budget truncation, change records | | [`src/state.ts`](src/state.ts) | Durable message sources, version/digest cache, reconciliation | | [`src/digest.ts`](src/digest.ts) | SHA-1 content identity and per-directory duplicate keys | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the durable context contract | +| — | No runtime invariant companion is published; replay intentionally tolerates unknown or malformed workspace sources, while focused pipeline tests own its private pending/cache state transitions. | ### Main flow diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index d187494ec0..0fed39a33f 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -93,7 +93,7 @@ export interface Config { | [`src/render.ts`](src/render.ts) | 指令渲染、预算截断、变更记录 | | [`src/state.ts`](src/state.ts) | 持久消息来源、版本/digest 缓存、对账 | | [`src/digest.ts`](src/digest.ts) | SHA-1 内容标识与每目录重复键 | -| [`src/invariant.ts`](src/invariant.ts) | 持久上下文约定的不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;回放会容忍未知或格式错误的 workspace source,私有 pending/cache 状态转换由聚焦 pipeline 测试覆盖。 | ### 主要流程 diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 7253ece9df..e121ec0aec 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -18,23 +18,17 @@ "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-agent": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -51,7 +45,6 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", diff --git a/packages/context/agent-instructions/src/invariant.ts b/packages/context/agent-instructions/src/invariant.ts deleted file mode 100644 index 7cd7e9d7e5..0000000000 --- a/packages/context/agent-instructions/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-agent-instructions`. - * @module @deepseek-ai/dsh-agent-instructions/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-agent-instructions' - -/** Cordis companion plugin name. */ -export const name = 'workspace-context-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources, - * while focused pipeline tests own its private pending/cache state transitions. - */ -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/context/agent-instructions/tsconfig.json b/packages/context/agent-instructions/tsconfig.json index b22904487a..5d52dc4597 100644 --- a/packages/context/agent-instructions/tsconfig.json +++ b/packages/context/agent-instructions/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../util/home-paths" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml index 15eb840259..1c36ae5e17 100644 --- a/packages/context/file-reference-local/README.i18n.yaml +++ b/packages/context/file-reference-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/context/file-reference-local/README.md -README.md: da5db0882e9f4538bdd901a970eca98aac10bbde -README.zh.md: cea38d335c8b29ddc3c875782cb62df1d0ae6e0d +README.md: 26137f04d0c5dde22a36f6361acd1d6386dc1633 +README.zh.md: bdb8bf1840597c7ad08aca258d4bd94b345623e0 diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md index da5db0882e..26137f04d0 100644 --- a/packages/context/file-reference-local/README.md +++ b/packages/context/file-reference-local/README.md @@ -71,7 +71,7 @@ The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at t |---|---| | [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`: config validation, per-agent searches, prompt install | | [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`: traversal, ranking, exclusion, staleness and background rebuild | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the discovery contract | +| — | No runtime invariant companion is published; per-agent indexes are private advisory caches whose invalidation and disposal are observed directly through service tests. | ### Main flow diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md index cea38d335c..bdb8bf1840 100644 --- a/packages/context/file-reference-local/README.zh.md +++ b/packages/context/file-reference-local/README.zh.md @@ -71,7 +71,7 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选 |---|---| | [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`:配置校验、按 agent 搜索、提示词安装 | | [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`:遍历、排序、排除、陈旧标记与后台重建 | -| [`src/invariant.ts`](src/invariant.ts) | 发现约定的不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;按 Agent 的 index 是私有 advisory cache,其失效与释放通过 service 测试直接观察。 | ### 主要流程 diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index c86b7350c6..1291516018 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -22,16 +22,11 @@ "types": "./lib/types/search.d.ts", "default": "./lib/types/search.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/**/*.js", "lib/types/**/*.d.ts" ], @@ -42,7 +37,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -50,7 +44,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/context/file-reference-local/src/invariant.ts b/packages/context/file-reference-local/src/invariant.ts deleted file mode 100644 index 168f261294..0000000000 --- a/packages/context/file-reference-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`. - * @module @deepseek-ai/dsh-file-reference-local/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-file-reference-local' - -/** Cordis companion plugin name. */ -export const name = 'file-reference-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: per-agent indexes are private advisory caches whose - * invalidation and disposal are observed directly through service tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/file-reference-local/tests/invariant.spec.ts b/packages/context/file-reference-local/tests/invariant.spec.ts deleted file mode 100644 index 7be7400d60..0000000000 --- a/packages/context/file-reference-local/tests/invariant.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as FileReferenceLocalInvariant from '../src/invariant.ts' - -describe('invariant companion', () => { - it('registers the provider cache ownership under its package name', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(FileReferenceLocalInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/context/file-reference-local/tsconfig.json b/packages/context/file-reference-local/tsconfig.json index 5b4c223f4a..5e2ae9c543 100644 --- a/packages/context/file-reference-local/tsconfig.json +++ b/packages/context/file-reference-local/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../core/tools" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../file-reference" } diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml index 5c7142368a..b78342d35c 100644 --- a/packages/context/file-reference/README.i18n.yaml +++ b/packages/context/file-reference/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/context/file-reference/README.md -README.md: 7571aecbc42cdcda39a2c7ad5416205e7f4c108c -README.zh.md: ad13336f47b3069fd35e75eb2aed994221bb9e3c +README.md: ce84a91e96988fb662ee665707d1ce2ad1651327 +README.zh.md: f4840179cebce060cc95cb4308b42d0715d1d5e5 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md index 7571aecbc4..ce84a91e96 100644 --- a/packages/context/file-reference/README.md +++ b/packages/context/file-reference/README.md @@ -60,7 +60,7 @@ The package separates an abstract discovery service from a shared, browser-safe | [`src/index.ts`](src/index.ts) | Abstract `FileReferenceService` and `FILE_REFERENCE_PROMPT` | | [`src/grammar.ts`](src/grammar.ts) | `activeAtToken` recognition and `formatFileMention` rendering | | [`src/types.ts`](src/types.ts) | `FileReferenceCandidate` path-only result type | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the discovery contract | +| — | No runtime invariant companion is published; the interface retains no candidate or lifecycle state; concrete providers own their cache and invalidation relationships. | ### Main flow diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md index ad13336f47..f4840179ce 100644 --- a/packages/context/file-reference/README.zh.md +++ b/packages/context/file-reference/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 抽象 `FileReferenceService` 与 `FILE_REFERENCE_PROMPT` | | [`src/grammar.ts`](src/grammar.ts) | `activeAtToken` 识别与 `formatFileMention` 渲染 | | [`src/types.ts`](src/types.ts) | 仅含路径的结果类型 `FileReferenceCandidate` | -| [`src/invariant.ts`](src/invariant.ts) | 发现约定的不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;接口不保留 candidate 或 lifecycle 状态;具体 provider 负责自己的 cache 与 invalidation 关系。 | ### 主要流程 diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index a84e7449ca..53bf6c00ee 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -22,10 +22,6 @@ "types": "./lib/types/grammar.d.ts", "default": "./lib/types/grammar.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -35,19 +31,16 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/file-reference/src/invariant.ts b/packages/context/file-reference/src/invariant.ts deleted file mode 100644 index cf623fd892..0000000000 --- a/packages/context/file-reference/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference`. - * @module @deepseek-ai/dsh-file-reference/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-file-reference' - -/** Cordis companion plugin name. */ -export const name = 'file-reference-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the interface retains no candidate or lifecycle - * state; concrete providers own their cache and invalidation relationships. - */ -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/context/file-reference/tests/invariant.spec.ts b/packages/context/file-reference/tests/invariant.spec.ts deleted file mode 100644 index 1b3f3177bd..0000000000 --- a/packages/context/file-reference/tests/invariant.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as FileReferenceInvariant from '../src/invariant.ts' - -describe('invariant companion', () => { - it('registers the stateless seam under its package name', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(FileReferenceInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/context/file-reference/tsconfig.json b/packages/context/file-reference/tsconfig.json index 142d6ed01b..7c07839ed0 100644 --- a/packages/context/file-reference/tsconfig.json +++ b/packages/context/file-reference/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../core/agent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index af85ae374c..615ca2a3fa 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/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/context/session-reference/README.md -README.md: 804cfea6357d4e9fc202e6562e75d740c5702c24 -README.zh.md: 4a249ea63cb7546603666990f67add54ebfe035a +README.md: 06ca90025aa0cc5e50fd3bea893ecf4bf3191077 +README.zh.md: 041a796a5bc708b5a7ed625a886defda24cdde3d diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 804cfea635..06ca90025a 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -73,7 +73,7 @@ Preparation reads each referenced session's current surface exactly once, when t | [`src/projection.ts`](src/projection.ts) | Current-surface projection and byte-budget retention | | [`src/serialization.ts`](src/serialization.ts) | Tag-safe JSON escaping for snapshot payloads | | [`src/types.ts`](src/types.ts) | `SessionReferenceInput`/`Candidate` and source types | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the reference contract | +| — | No runtime invariant companion is published; preparation returns immutable per-call snapshots validated while they are built, and the agent/session layers own durable context admission, freezing, and replay. | ### Main flow diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 4a249ea63c..041a796a5b 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -73,7 +73,7 @@ kind: "package-reference" | [`src/projection.ts`](src/projection.ts) | 当前表层投影与字节预算保留 | | [`src/serialization.ts`](src/serialization.ts) | 快照载荷的标签安全 JSON 转义 | | [`src/types.ts`](src/types.ts) | `SessionReferenceInput`/`Candidate` 与来源类型 | -| [`src/invariant.ts`](src/invariant.ts) | 引用约定的不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;prepare 返回构建时已校验的不可变单次快照;持久 context 的准入、冻结与回放由 Agent 和 Session 层负责。 | ### 主要流程 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 72c5bb1891..48c0f67d5a 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -39,7 +35,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/typert.host.js", @@ -55,7 +50,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -74,7 +68,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/context/session-reference/src/invariant.ts b/packages/context/session-reference/src/invariant.ts deleted file mode 100644 index 9a277e7614..0000000000 --- a/packages/context/session-reference/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-reference`. - * @module @deepseek-ai/dsh-session-reference/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-session-reference' - -/** Cordis companion plugin name. */ -export const name = 'session-reference-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: preparation returns immutable per-call snapshots validated while they are - * built, and the agent/session layers own durable context admission, freezing, and replay. - */ -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/context/session-reference/tsconfig.json b/packages/context/session-reference/tsconfig.json index 47d2f50e91..25a078fcb1 100644 --- a/packages/context/session-reference/tsconfig.json +++ b/packages/context/session-reference/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../compaction/compaction" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../session-query/session-query" }, diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index fabbeac283..fc77202018 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/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/context/tmux-context/README.md -README.md: d923502a990f28a4f5267b7f39a552dbaf7423aa -README.zh.md: d1636e62cd026f417a82f07542b31a89302ca081 +README.md: df94e309839a8ecdae902def8531856071269d20 +README.zh.md: da69791cb96cd4ef13b5fa31ddaf4e6470e08d60 diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index d923502a99..df94e30983 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -70,7 +70,7 @@ The plugin prepends an `agent/pre-step` listener that runs only on the first ste | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: first-step listener, shell query, change suppression, scheduling | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the snapshot contract | +| — | No runtime invariant companion is published; a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to check; scheduling and format are owned by pipeline tests. | ### Main flow diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index d1636e62cd..da69791cb9 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:第一步监听器、shell 查询、变化抑制、调度 | -| [`src/invariant.ts`](src/invariant.ts) | 快照约定的不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;每次读取都是外部 tmux 状态的单轮快照,Session 没有可检查的跨事件关系;调度与格式由 pipeline 测试负责。 | ### 主要流程 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index b90a6aa230..4c1ae48774 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -18,16 +18,11 @@ "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", @@ -38,7 +33,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^" @@ -46,7 +40,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts deleted file mode 100644 index 901f7c4043..0000000000 --- a/packages/context/tmux-context/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`. - * @module @deepseek-ai/dsh-tmux-context/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-tmux-context' - -/** Cordis companion plugin name. */ -export const name = 'tmux-context-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session - * holds no cross-event relation to check; scheduling and format are owned by pipeline tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json index 42fa931fa2..f0ae6cddb3 100644 --- a/packages/context/tmux-context/tsconfig.json +++ b/packages/context/tmux-context/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../core/system-prompt" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/session" }, diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index fe0f11b762..1bae31f6c4 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: e9f326b9d7d17a5bd10f0b8df1d8105ea1522c43 -README.zh.md: cb7e3c76d87850df89c2a6ff7aa8d4a17b77bd70 +README.md: b203826d2b5ae05026881fff99d4571b470c5fc8 +README.zh.md: 24c2202302d8348701615619c3b68f086a0d5273 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index e9f326b9d7..b203826d2b 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -75,7 +75,7 @@ The service is a composition entry with a settings-backed source. The plugin con | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentDefaultModelConfig` service, settings section install, `currentSelection`/`saveSelection` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; settings validation owns the only mutable-value relationship. | ### Behavior notes diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index cb7e3c76d8..24c2202302 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -75,7 +75,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`AgentDefaultModelConfig` 服务、设置分节安装、`currentSelection`/`saveSelection` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式配套 | +| — | 不发布运行时不变式伴生入口;唯一的可变值关系由 settings 校验负责。 | ### 行为说明 diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index d236420235..c06ea2af65 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -18,16 +18,11 @@ "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", @@ -36,14 +31,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/core/agent-default-model/src/invariant.ts b/packages/core/agent-default-model/src/invariant.ts deleted file mode 100644 index 48253ae159..0000000000 --- a/packages/core/agent-default-model/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for the default Agent model selection. - * - * The service owns no independent event relationship: settings registration - * already validates every mutable value before `currentSelection()` can observe it. - * The empty installer keeps that absence explicit in composed invariant sets. - * - * @module @deepseek-ai/dsh-agent-default-model/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-agent-default-model' - -/** Cordis companion plugin name. */ -export const name = 'agent-default-model-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** No runtime invariant: settings validation owns the only mutable-value relationship. */ -const install: InvariantInstaller = () => {} - -/** - * Register the intentionally empty invariant contribution. - * @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)) diff --git a/packages/core/agent-default-model/tsconfig.json b/packages/core/agent-default-model/tsconfig.json index bf2de8e3ce..c90df2e6dd 100644 --- a/packages/core/agent-default-model/tsconfig.json +++ b/packages/core/agent-default-model/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/core/agent-default-model/tsdown.config.ts b/packages/core/agent-default-model/tsdown.config.ts index ab8dc26ee8..d593c00b27 100644 --- a/packages/core/agent-default-model/tsdown.config.ts +++ b/packages/core/agent-default-model/tsdown.config.ts @@ -12,14 +12,4 @@ export default defineConfig([ dts: false, clean: false, }, - { - entry: ['lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, ]) diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index 7f29e5c109..4bd495ba8e 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/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/core/agent-tool-presentation/README.md -README.md: c57938f2b32bead9558b283112c09e8a46b6e866 -README.zh.md: ac511f0793c1f883d539fd05cc53655869f19f5d +README.md: 003734b6d05de40f0972632d01392dd201fa4232 +README.zh.md: 552812932adfdf344b51c9d7dffb679b8e23e5b2 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index c57938f2b3..003734b6d0 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -68,7 +68,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `mode` config, `apply` wiring `ctx.tools.presentAs` for the mounting scope | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this package makes exactly one scoped call into `ctx.tools` and owns no event or snapshot of its own; the relation it establishes — which presentation one agent's assembly uses — is the tool registry's to hold, and `dsh-tools` observes it there. | ### Behavior notes diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index ac511f0793..552812932a 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`mode` 配置、把 `ctx.tools.presentAs` 接到挂载作用域的 `apply` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式配套 | +| — | 不发布运行时不变式伴生入口;本包只对 `ctx.tools` 发起一次 scoped 调用,不持有事件或快照;所选 presentation 的关系由 tool registry 持有并由 `dsh-tools` 观察。 | ### 行为说明 diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 51ef164ac2..c3affd4af8 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -18,16 +18,11 @@ "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", @@ -35,14 +30,12 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/core/agent-tool-presentation/src/invariant.ts b/packages/core/agent-tool-presentation/src/invariant.ts deleted file mode 100644 index 00c7e3d77c..0000000000 --- a/packages/core/agent-tool-presentation/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-presentation`. - * @module @deepseek-ai/dsh-agent-tool-presentation/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-agent-tool-presentation' - -/** Cordis companion plugin name. */ -export const name = 'tool-presentation-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package makes exactly one scoped call into - * `ctx.tools` and owns no event or snapshot of its own; the relation it - * establishes — which presentation one agent's assembly uses — is the tool - * registry's to hold, and `dsh-tools` observes it there. - */ -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/core/agent-tool-presentation/tsconfig.json b/packages/core/agent-tool-presentation/tsconfig.json index 93528e61f3..a3e6344b9d 100644 --- a/packages/core/agent-tool-presentation/tsconfig.json +++ b/packages/core/agent-tool-presentation/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 3e63d1ed5e..bda0f39b1a 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-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/credentials/credentials-local/README.md -README.md: b46bdaaecc6758f787885f0f153372a2b67c3fee -README.zh.md: b8362233f7046dc99571de4f4ddc92f59763b793 +README.md: 79c3bb5a2c7cd7ca76efaa02d2919dbdff79994f +README.zh.md: c1f1a57850f2220a0f0c28fe2c50fbc7374f6968 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index b46bdaaecc..79c3bb5a2c 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -143,7 +143,7 @@ This section explains the design decisions behind the provider and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Provider: layer resolution, strict document parse, reference and record write paths under the writer lock, watcher lifecycle, permissions check | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam companion owns the event lifecycle contract) | +| — | No runtime invariant companion is published; the Service Definition companion (`dsh-credentials/invariant`) owns the `credentials/reference-updated` lifecycle contract; this provider's file/environment layering is asynchronous I/O pinned by its unit suite. | ### Resolution and write paths diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index b8362233f7..c1f1a57850 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -143,7 +143,7 @@ records: | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 提供方:层解析、严格文档解析、写锁下的引用与记录写路径、watcher 生命周期、权限检查 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;事件生命周期约定归 seam 伴生插件) | +| — | 不发布运行时不变式伴生入口;事件生命周期约定归 seam 伴生插件。 | ### 解析与写入路径 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 22ad915e46..6601f83dc1 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -18,16 +18,11 @@ "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", @@ -35,7 +30,6 @@ "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -48,7 +42,6 @@ "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts deleted file mode 100644 index 4f584e135b..0000000000 --- a/packages/credentials/credentials-local/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-credentials-local`. - * @module @deepseek-ai/dsh-credentials-local/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-credentials-local' - -/** Cordis companion plugin name. */ -export const name = 'credentials-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the Service Definition companion (`dsh-credentials/invariant`) owns the - * `credentials/reference-updated` lifecycle contract; this provider's file/environment layering is - * asynchronous I/O pinned by its unit suite. - */ -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/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json index 214466a853..aaf11173c4 100644 --- a/packages/credentials/credentials-local/tsconfig.json +++ b/packages/credentials/credentials-local/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../credentials" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index d87e58df7e..8876083dec 100644 --- a/packages/e2b/e2b/README.i18n.yaml +++ b/packages/e2b/e2b/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/e2b/e2b/README.md -README.md: 837de6f3487de4aad5fc745e0fc3bb6899adfbf7 -README.zh.md: fadab86f28033396ca129ce4abfb2249f5e9c658 +README.md: 7d8c5728ee5e8243165338cddb1930f34e8f7d3f +README.zh.md: b4f0854d6b76875fcadd9cc07a4b014310a0cfbb diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index 837de6f348..7d8c5728ee 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -83,7 +83,7 @@ This section explains the design decisions behind the owner and points at the co | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `E2BRuntime` service, `Config` schema, validation, sandbox open and teardown | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; sandbox creation and teardown have one SDK promise and no independent event or mutable-data relationship) | +| — | No runtime invariant companion is published; sandbox creation and teardown have one SDK promise and no independent event or mutable-data relationship to cross-check. | ### Lifecycle diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index fadab86f28..b4f0854d6b 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`E2BRuntime` 服务、`Config` schema、校验、沙箱创建与拆除 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;沙箱创建与拆除只有一个 SDK promise,没有可交叉核对的独立事件或可变数据关系) | +| — | 不发布运行时不变式伴生入口;沙箱创建与拆除只有一个 SDK promise,没有可交叉核对的独立事件或可变数据关系。 | ### 生命周期 diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index a94691bbfe..5fab9d6d84 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -18,21 +18,15 @@ "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": { @@ -44,7 +38,6 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-fs-e2b": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", "@deepseek-ai/dsh-lsp-stdio": "workspace:^", diff --git a/packages/e2b/e2b/src/invariant.ts b/packages/e2b/e2b/src/invariant.ts deleted file mode 100644 index 63bf988e50..0000000000 --- a/packages/e2b/e2b/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-e2b`. - * @module @deepseek-ai/dsh-e2b/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-e2b' - -/** Cordis companion plugin name. */ -export const name = 'e2b-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: sandbox creation and teardown have one SDK promise and - * no independent event or mutable-data relationship to cross-check. - */ -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/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index e2d3ad3d6f..e1007dfbef 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -8,8 +8,6 @@ import E2BRuntime, { SandboxNotFoundError, quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' -import * as E2BInvariant from '../src/invariant.ts' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' const sdk = vi.hoisted(() => ({ create: vi.fn(), @@ -233,15 +231,8 @@ describe('E2BRuntime', () => { }) }) -describe('E2B helpers and invariant companion', () => { +describe('E2B helpers', () => { it('quotes opaque shell arguments without interpolation', () => { expect(quoteE2BShellArg("a'b $HOME")).toBe("'a'\"'\"'b $HOME'") }) - - it('registers the package-owned empty invariant installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = await ctx.plugin(E2BInvariant).await() - await fiber.dispose() - }) }) diff --git a/packages/e2b/e2b/tsconfig.json b/packages/e2b/e2b/tsconfig.json index ff089e1e58..9d1380b4f8 100644 --- a/packages/e2b/e2b/tsconfig.json +++ b/packages/e2b/e2b/tsconfig.json @@ -17,9 +17,6 @@ }, { "path": "../../util/brand" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index c46b483997..dd50944ac8 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-e2b/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/e2b/fs-e2b/README.md -README.md: dcc158af6dcb1b9b4dc6fefcb4bbafbe425bc64b -README.zh.md: 2e2242748949f21b1709600566b37d4d7ba75c70 +README.md: 3219bd55425da17781c84b69564bb71adccaf077 +README.zh.md: 3bd31796c4d49af72c1795da1feea4efb9407318 diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index dcc158af6d..3219bd5542 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -75,7 +75,7 @@ This section explains the design decisions behind the provider and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `E2BFileSystem` provider, canonicalization, reads, atomic writes, error mapping | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; each operation returns the controller's committed result directly) | +| — | No runtime invariant companion is published; each operation returns the E2B controller's committed result directly, with no independent event or cache to cross-check. | ### Canonical paths and transport framing diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index 2e22427489..3bd31796c4 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -75,7 +75,7 @@ agent 可以创建文件、覆盖文件,或通过替换一段字面量文本 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`E2BFileSystem` 提供方、规范化、读取、原子写入、错误映射 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;每个操作都直接返回控制器的已提交结果) | +| — | 不发布运行时不变式伴生入口;每个操作都直接返回控制器的已提交结果。 | ### 规范化路径与传输分帧 diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index d2dce5c4c7..f0ba24c158 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -18,29 +18,22 @@ "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-e2b": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/fs-e2b/src/invariant.ts b/packages/e2b/fs-e2b/src/invariant.ts deleted file mode 100644 index 891b157aec..0000000000 --- a/packages/e2b/fs-e2b/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-fs-e2b`. - * @module @deepseek-ai/dsh-fs-e2b/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-fs-e2b' - -/** Cordis companion plugin name. */ -export const name = 'fs-e2b-invariant' -/** Service required before reserving package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: each operation returns the E2B controller's committed - * result directly, with no independent event or cache to cross-check. - */ -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/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 97e549e84b..516e3e6e39 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -11,8 +11,6 @@ import { import type E2BRuntime from '@deepseek-ai/dsh-e2b' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b' -import * as E2BFsInvariant from '../src/invariant.ts' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { describe, expect, it, vi } from 'vitest' interface RemoteNode { @@ -797,11 +795,4 @@ describe('E2B filesystem adapter integration edges', () => { expect(remote.commands.slice(commandsBefore)).toHaveLength(2) expect(getInfo).toHaveBeenCalledTimes(3) }) - - it('registers the package-owned empty invariant installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = await ctx.plugin(E2BFsInvariant).await() - await fiber.dispose() - }) }) diff --git a/packages/e2b/fs-e2b/tsconfig.json b/packages/e2b/fs-e2b/tsconfig.json index 4f0326a5f1..e8d52a194c 100644 --- a/packages/e2b/fs-e2b/tsconfig.json +++ b/packages/e2b/fs-e2b/tsconfig.json @@ -17,9 +17,6 @@ }, { "path": "../../fs/fs" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index fb7da041fb..f0793cce4a 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/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/e2b/subprocess-e2b/README.md -README.md: 72f345aeddb632ef708c1e41795fb1307fc9dd6e -README.zh.md: 3770c060373507c06cc1c4851de997ea0ab1d5e3 +README.md: 28f1b3410781fd87e40527d70e823bd9111d57e7 +README.zh.md: 3d9a35dea74126ae4cc5d381944b2fb5a86f61ee diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 72f345aedd..28f1b34107 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the provider and points at the | [`src/environment.ts`](src/environment.ts) | Remote environment probe, scrubbing, serialization | | [`src/output.ts`](src/output.ts) | Base64 decoder and bounded output readers | | [`src/remote.ts`](src/remote.ts) | Shared control-shell helpers: option shaping, poll ticks, group signalling | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; live remote handles are private teardown ownership) | +| — | No runtime invariant companion is published; live remote handles are private teardown ownership, and the E2B command event stream is the sole outcome authority. | ### Remote wrapper diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 3770c06037..3d9a35dea7 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -85,7 +85,7 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出, | [`src/environment.ts`](src/environment.ts) | 远程环境探测、清理、序列化 | | [`src/output.ts`](src/output.ts) | base64 解码器与有界输出读取器 | | [`src/remote.ts`](src/remote.ts) | 共享控制 shell 辅助:选项构造、轮询 tick、进程组信号 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;存活远程句柄是私有的拆除所有权) | +| — | 不发布运行时不变式伴生入口;存活远程句柄是私有的拆除所有权。 | ### 远程包装层 diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index c05d938c21..40b5c65061 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -18,22 +18,16 @@ "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-e2b": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -43,7 +37,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-e2b": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/e2b/subprocess-e2b/src/invariant.ts b/packages/e2b/subprocess-e2b/src/invariant.ts deleted file mode 100644 index 733310245a..0000000000 --- a/packages/e2b/subprocess-e2b/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-e2b`. - * @module @deepseek-ai/dsh-subprocess-e2b/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-subprocess-e2b' - -/** Cordis companion plugin name. */ -export const name = 'subprocess-e2b-invariant' -/** Service required before reserving package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: live remote handles are private teardown ownership, - * and the E2B command event stream is the sole outcome authority. - */ -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/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 933e7e0190..fe79c60720 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -11,10 +11,8 @@ import { import type E2BRuntime from '@deepseek-ai/dsh-e2b' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b' -import * as E2BSubprocessInvariant from '../src/invariant.ts' import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from '../src/output.ts' import { E2BSubprocessHandle } from '../src/process.ts' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { describe, expect, it, vi } from 'vitest' function commandError(exitCode: number): CommandExitError { @@ -1782,11 +1780,4 @@ describe('E2BSubprocessRuntime', () => { expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/) expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/) }) - - it('registers the package-owned empty invariant installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = await ctx.plugin(E2BSubprocessInvariant).await() - await fiber.dispose() - }) }) diff --git a/packages/e2b/subprocess-e2b/tsconfig.json b/packages/e2b/subprocess-e2b/tsconfig.json index 86942f037b..8a35f8298a 100644 --- a/packages/e2b/subprocess-e2b/tsconfig.json +++ b/packages/e2b/subprocess-e2b/tsconfig.json @@ -15,10 +15,10 @@ "path": "../../../vendor/cosmokit" }, { - "path": "../../subprocess/subprocess" + "path": "../../../vendor/schemastery" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../subprocess/subprocess" }, { "path": "../../util/timeout" diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index d6dd65615e..52d3c9a9aa 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 727effb9f6c9c4b1b5af6c7f3da8aea50b0641fa -README.zh.md: df84c48e8271cdddcbf68e7659e8014e0c223ae1 +README.md: 900c794913f4cc0d7682dcd4d2987ba1d2fd2e87 +README.zh.md: e4ad7b98be6bf0a0a9613b3212414ed4a0bb9920 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 727effb9f6..900c794913 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -131,14 +131,13 @@ A YAML include can deduplicate config but cannot own a bin or provide entry-poin ### Invariant companions -The bundle mounts the invariant registry and its four package companions (`session`, `agent`, `scope`, `agent-loop`). `invariants.enabled: false` or package filters suppress the checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. The package's own companion ([`src/invariant.ts`](src/invariant.ts)) installs no runtime invariant because this composition package owns no independent event stream or mutable data. +**Runtime invariant:** No companion is published. This composition package owns no independent event stream or mutable data; Loader and built-entry tests cover its wiring. ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `pickSpineConfig()`, `apply()` mounting every child | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; composition wiring is covered by tests) | diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index df84c48e82..e4ad7b98be 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -131,14 +131,13 @@ YAML include 可以去重配置,却无法拥有 bin 或提供入口默认值 ### 不变式配套插件 -组合包挂载不变式注册表及其四个包配套插件(`session`、`agent`、`scope`、`agent-loop`)。`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。本包自己的配套插件([`src/invariant.ts`](src/invariant.ts))不安装任何运行时不变式,因为这个组合包不拥有独立事件流或可变数据。 +**运行时不变式:** 不发布伴生入口。本组合包不持有独立事件流或可变数据;所挂载的生产包分别负责自己的运行时不变式。 ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`pickSpineConfig()`、挂载所有子插件的 `apply()` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式配套插件(无运行时不变式;组合接线由测试覆盖) | diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index fdea770f50..757cb9a866 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -18,16 +18,11 @@ "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", diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts deleted file mode 100644 index 9735d8e74d..0000000000 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-agent-spine-demo`. - * @module @deepseek-ai/dsh-agent-spine-demo/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-agent-spine-demo' - -/** Cordis companion plugin name. */ -export const name = 'agent-spine-demo-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this composition package owns no independent event stream or mutable data; - * Loader and built-entry tests cover its wiring. - */ -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/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 7fd6eae729..170209af37 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/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/agent-team-profile/README.md -README.md: 7b0e2172b88287998f346d32010eb73ef3619eae -README.zh.md: b7323ebed66fe8dfc9c47749223d252a76e13363 +README.md: 0c0d0cd24b0c4fca52421e8908e5ae196429265d +README.zh.md: 83c8a8f55e4d9d1bfcbc6ee85f8ce3e5c695fa82 diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 7b0e2172b8..0c0d0cd24b 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -54,7 +54,7 @@ The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | Ordered patch over `dsh-base` | | [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content | -| [`src/invariant.ts`](src/invariant.ts) | Empty invariant companion for the static bundle | +| — | No runtime invariant companion is published; the package carries only a static profile patch. The Team domain and tool packages own the mutable relationships it activates. | diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index b7323ebed6..83c8a8f55e 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -54,7 +54,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的有序 patch | | [`src/index.ts`](src/index.ts) | 空模块入口;patch 是运行时内容 | -| [`src/invariant.ts`](src/invariant.ts) | 静态 bundle 的空不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包是静态 bundle,不持有可独立观察的运行时关系。 | diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index fcdfa983ee..55dcc7f39a 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -16,17 +16,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -41,12 +36,10 @@ "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "js-yaml": "^4.2.0" } diff --git a/packages/experimental/agent-team-profile/src/invariant.ts b/packages/experimental/agent-team-profile/src/invariant.ts deleted file mode 100644 index 38db2de5ff..0000000000 --- a/packages/experimental/agent-team-profile/src/invariant.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-experimental-agent-team-profile`. - * @module @deepseek-ai/dsh-experimental-agent-team-profile/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-agent-team-profile' - -/** Cordis companion plugin name. */ -export const name = 'agent-team-profile-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -// No runtime invariant: the package carries only a static profile patch. The -// Team domain and tool packages own the mutable relationships it activates. -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)) diff --git a/packages/experimental/agent-team-profile/tsconfig.json b/packages/experimental/agent-team-profile/tsconfig.json index 9fd6ff90fc..c1eaa3f96e 100644 --- a/packages/experimental/agent-team-profile/tsconfig.json +++ b/packages/experimental/agent-team-profile/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../agent-team" }, diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/packages/experimental/agent-team-web-profile/README.i18n.yaml index e2e2525058..06c4db39d8 100644 --- a/packages/experimental/agent-team-web-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-web-profile/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/agent-team-web-profile/README.md -README.md: 8b7c4985a9322c256e156e51f9692e360ccdbe27 -README.zh.md: bbf92766bfbb391ffd1cf98a859c7329f48fb49a +README.md: 07dd8f3131ebe6418861e584fabba9908a3fbc4d +README.zh.md: 9c63866caad40d4b50e24aacd25998c5db2508b6 diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md index 8b7c4985a9..07dd8f3131 100644 --- a/packages/experimental/agent-team-web-profile/README.md +++ b/packages/experimental/agent-team-web-profile/README.md @@ -54,7 +54,7 @@ The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | Ordered Web patch containing the `ui-agent-team` row | | [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content | -| [`src/invariant.ts`](src/invariant.ts) | Empty invariant companion for the static bundle | +| — | No runtime invariant companion is published; the package carries only a static profile patch. The Remote assembly and Team UI own their activation requirements. | diff --git a/packages/experimental/agent-team-web-profile/README.zh.md b/packages/experimental/agent-team-web-profile/README.zh.md index bbf92766bf..9c63866caa 100644 --- a/packages/experimental/agent-team-web-profile/README.zh.md +++ b/packages/experimental/agent-team-web-profile/README.zh.md @@ -54,7 +54,7 @@ pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | 包含 `ui-agent-team` 行的有序 Web patch | | [`src/index.ts`](src/index.ts) | 空模块入口;patch 是运行时内容 | -| [`src/invariant.ts`](src/invariant.ts) | 静态 bundle 的空不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包是静态 bundle,不持有可独立观察的运行时关系。 | diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 0b29840755..aa1531ea6a 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -16,17 +16,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -40,12 +35,10 @@ "@deepseek-ai/dsh-experimental-client-ui-agent-team": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "js-yaml": "^4.2.0" } diff --git a/packages/experimental/agent-team-web-profile/src/invariant.ts b/packages/experimental/agent-team-web-profile/src/invariant.ts deleted file mode 100644 index 6a219f5961..0000000000 --- a/packages/experimental/agent-team-web-profile/src/invariant.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Package-owned invariant companion for the Agent Teams Web profile. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-agent-team-web-profile' - -/** Cordis companion plugin name. */ -export const name = 'agent-team-web-profile-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -// No runtime invariant: the package carries only a static profile patch. The -// Remote assembly and Team UI own their activation requirements. -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)) diff --git a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts index 6cd0a31ef4..73097a826f 100644 --- a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts +++ b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts @@ -4,11 +4,8 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import * as WebProfileInvariant from '../src/invariant.ts' describe('Agent Teams Web profile bundle', () => { it('declares a private parseable layer containing the Team UI', () => { @@ -34,17 +31,4 @@ describe('Agent Teams Web profile bundle', () => { { id: 'ui-agent-team', name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' }, ]) }) - - it('reserves package ownership without installing a runtime audit', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = ctx.plugin(WebProfileInvariant) - await fiber.await() - expect(WebProfileInvariant.name).toBe('agent-team-web-profile-invariant') - expect(WebProfileInvariant.inject).toEqual(['invariants']) - expect(() => { - Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) - }).not.toThrow() - await fiber.dispose() - }) }) diff --git a/packages/experimental/agent-team-web-profile/tsconfig.json b/packages/experimental/agent-team-web-profile/tsconfig.json index 440bba72ae..d4186340bf 100644 --- a/packages/experimental/agent-team-web-profile/tsconfig.json +++ b/packages/experimental/agent-team-web-profile/tsconfig.json @@ -6,7 +6,6 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../../../vendor/cordis" } ] } diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index feb6b05168..ab225f45d5 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/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/client-ui-agent-team/README.md -README.md: 3980608d4fbe7539c454c874fd9334d323048be9 -README.zh.md: 082e32d83ed0a936b06ff03f43c81aec918405a5 +README.md: 04083ae2d2a54dfe0c1f1c43dee227e9ae575a81 +README.zh.md: a75d11af89262e562a91117c9653ef0e89a88cd8 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 3980608d4f..04083ae2d2 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -94,3 +94,5 @@ No direct effect; the Team tools and ordinary conversation submission own any la None. + +**Runtime invariant:** No companion is published. RPC is authoritative and the package owns only one disposable slot registration. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 082e32d83e..a75d11af89 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -94,3 +94,5 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. 无。 + +**运行时不变式:** 不发布伴生入口。RPC 是权威来源,本包只持有一个可释放的 slot 注册。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index c21cd7a6c2..3748bb0c82 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -16,10 +16,6 @@ "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" @@ -57,7 +53,6 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -73,7 +68,6 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@testing-library/react": "^16.1.0", @@ -83,7 +77,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/experimental/client-ui-agent-team/src/invariant.ts b/packages/experimental/client-ui-agent-team/src/invariant.ts deleted file mode 100644 index c40b74d759..0000000000 --- a/packages/experimental/client-ui-agent-team/src/invariant.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Package-owned invariant companion for the Team Web presentation. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-client-ui-agent-team' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-agent-team-invariant' -/** Invariant registry dependency. */ -export const inject = ['invariants'] - -/** No runtime invariant: RPC is authoritative and the package owns only one disposable slot registration. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant ownership. - * @param ctx - Cordis Context carrying the invariant registry. - * @returns disposer for the package registration. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/client-ui-agent-team/tsconfig.json b/packages/experimental/client-ui-agent-team/tsconfig.json index edd7ca15c0..181f1585d6 100644 --- a/packages/experimental/client-ui-agent-team/tsconfig.json +++ b/packages/experimental/client-ui-agent-team/tsconfig.json @@ -17,7 +17,6 @@ { "path": "../../client/ui-session" }, { "path": "../../client/ui-slots" }, { "path": "../../core/session" }, - { "path": "../../typert/protocol" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../../typert/protocol" } ] } diff --git a/packages/experimental/client-ui-agent-team/tsdown.config.ts b/packages/experimental/client-ui-agent-team/tsdown.config.ts index ee51c1326f..f76b2c66fe 100644 --- a/packages/experimental/client-ui-agent-team/tsdown.config.ts +++ b/packages/experimental/client-ui-agent-team/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-experimental-client-ui-agent-team', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-experimental-client-ui-agent-team', ['lib/types/index.js']) diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 2193de34a3..b73404938a 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/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/inspector/README.md -README.md: d6c519723a8b9eab31048fb71f13b17004a6af52 -README.zh.md: 0c2f58032a7c1f1a485f974da60c1d60e335eb51 +README.md: 956a316430b69cc04535296136542ea5ba4580de +README.zh.md: 4cd23fcb125af20bd82ade510ac29dcda0430c11 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index d6c519723a..956a316430 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -152,3 +152,5 @@ None; this package neither assembles nor sends a provider request. None. + +**Runtime invariant:** No companion is published. Wire parsing, generations, Worker lifecycle, and CDP sessions reject invalid relationships in their owning operations. diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index 0c2f58032a..4cd23fcb12 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -152,3 +152,5 @@ CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中 无。 + +**运行时不变式:** 不发布伴生入口。wire 解析、generation、Worker 生命周期与 CDP session 会在所属操作中拒绝无效关系。 diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 34bc8c9a1c..e520bfa997 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -20,10 +20,6 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -36,7 +32,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ], @@ -51,15 +46,13 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-host-webserver": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/ws": "^8.18.1", "playwright": "^1.49.0", "tsx": "^4.19.2" diff --git a/packages/experimental/inspector/src/invariant.ts b/packages/experimental/inspector/src/invariant.ts deleted file mode 100644 index 33dccfbe9e..0000000000 --- a/packages/experimental/inspector/src/invariant.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Package-owned invariant companion for the experimental Inspector. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-inspector' - -/** Cordis companion plugin name. */ -export const name = 'experimental-inspector-invariant' - -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: wire parsing, generations, Worker lifecycle, and CDP - * sessions reject invalid relationships in their owning operations. - */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 586e720245..46bbad99be 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -182,6 +182,21 @@ describe('Cordis tree inspection', () => { expect(byteBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } }) byteBound.close() + const nestedRoot = new Context() + const outerFiber = nestedRoot.plugin({ name: 'outer', apply() {} }) + await outerFiber.await() + const innerFiber = outerFiber.ctx.isolate('nested').plugin({ name: 'inner', apply() {} }) + await innerFiber.await() + const nestedComplete = new CordisTreeCollector(nestedRoot, { maxNodes: 100, maxBytes: 64 * 1_024 }) + const nestedBytes = jsonByteLength(nestedComplete.snapshot() as unknown as InspectorJsonValue) + nestedComplete.close() + const nestedBound = new CordisTreeCollector(nestedRoot, { maxNodes: 100, maxBytes: nestedBytes - 1 }) + const nestedSnapshot = nestedBound.snapshot() + expect(nestedSnapshot.truncated).toBe(true) + expect(treeNodes(nestedSnapshot.root) + .some(node => node.kind === 'fiber' && node.uid === innerFiber.uid)).toBe(false) + nestedBound.close() + const impossible = new CordisTreeCollector(root, { maxNodes: 0, maxBytes: 1 }) expect(() => impossible.snapshot()).toThrow('maxNodes cannot retain the root Context') impossible.close() @@ -189,6 +204,8 @@ describe('Cordis tree inspection', () => { const rootTooLarge = new CordisTreeCollector(new Context(), { maxNodes: 2, maxBytes: 1 }) expect(() => rootTooLarge.snapshot()).toThrow('Cordis root exceeds the source-frame byte limit') rootTooLarge.close() + await innerFiber.dispose() + await outerFiber.dispose() await directFiber.dispose() await fiber.dispose() }) diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json index 9ba0233518..f96992b4aa 100644 --- a/packages/experimental/inspector/tsconfig.host.json +++ b/packages/experimental/inspector/tsconfig.host.json @@ -29,7 +29,6 @@ "src/host/inspection/realm.ts", "src/host/plugin.ts", "src/index.ts", - "src/invariant.ts", "src/shared/bridge/buffer.ts", "src/shared/bridge/codec.ts", "src/shared/bridge/control-codec.ts", @@ -147,9 +146,6 @@ { "path": "../../host/webserver" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/crypto" } diff --git a/packages/experimental/inspector/tsdown.config.ts b/packages/experimental/inspector/tsdown.config.ts index 349255c09b..1265536bd7 100644 --- a/packages/experimental/inspector/tsdown.config.ts +++ b/packages/experimental/inspector/tsdown.config.ts @@ -17,6 +17,6 @@ const worker: UserConfig = { /** Build the Host plugin and Worker during the Host pass, and the dynamic Client plugin during the Client pass. */ export default clientBundle( '@deepseek-ai/dsh-experimental-inspector', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], { hostPhase: true, companions: [worker] }, ) diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml index 70d330dac0..df245ca2ed 100644 --- a/packages/experimental/tool-agent-team/README.i18n.yaml +++ b/packages/experimental/tool-agent-team/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/tool-agent-team/README.md -README.md: 7b52e2fd43a3bbc288282f0e39b93e51bb0cc15c -README.zh.md: 5e9f0b4ed5707ef7adaed978d255f6f570e29cf7 +README.md: b8474117d429d6f03562479834bb13daad3307b9 +README.zh.md: ca4dd17e0ccafef4112655f80543d539c9a11a4b diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md index 7b52e2fd43..b8474117d4 100644 --- a/packages/experimental/tool-agent-team/README.md +++ b/packages/experimental/tool-agent-team/README.md @@ -92,7 +92,7 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08- | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: config, the fixed policy text, and the ten scoped tool registrations | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; delegation is observable only through `ctx.agentTeams`) | +| — | No runtime invariant companion is published; the Team service owns durable and authorization relations. | ### Policy and tools diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md index 5e9f0b4ed5..ca4dd17e0c 100644 --- a/packages/experimental/tool-agent-team/README.zh.md +++ b/packages/experimental/tool-agent-team/README.zh.md @@ -92,7 +92,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:配置、固定策略文本与十个 scoped 工具注册 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;委托只能通过 `ctx.agentTeams` 观察) | +| — | 不发布运行时不变式伴生入口;委托只能通过 `ctx.agentTeams` 观察。 | ### 策略与工具 diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index f04bb5d9c6..f2bef67e52 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -16,16 +16,11 @@ "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", @@ -34,7 +29,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", @@ -45,7 +39,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/experimental/tool-agent-team/src/invariant.ts b/packages/experimental/tool-agent-team/src/invariant.ts deleted file mode 100644 index d6ab0dfbc4..0000000000 --- a/packages/experimental/tool-agent-team/src/invariant.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Package-owned invariant companion for the Team tool adapter. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-tool-agent-team' - -/** Cordis companion plugin name. */ -export const name = 'tool-team-invariant' -/** Invariant registry dependency. */ -export const inject = ['invariants'] - -/** No runtime invariant: the Team service owns durable and authorization relations. */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant ownership. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/tool-agent-team/tsconfig.json b/packages/experimental/tool-agent-team/tsconfig.json index b2b95b34ad..d4f77f93e5 100644 --- a/packages/experimental/tool-agent-team/tsconfig.json +++ b/packages/experimental/tool-agent-team/tsconfig.json @@ -14,7 +14,6 @@ { "path": "../../core/session" }, { "path": "../../core/system-prompt" }, { "path": "../../core/tools" }, - { "path": "../agent-team" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../agent-team" } ] } diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml index 0eef8f761b..873df39ef1 100644 --- a/packages/experimental/webworker-packer/README.i18n.yaml +++ b/packages/experimental/webworker-packer/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/webworker-packer/README.md -README.md: fd4f9793b17ad66ae4c5f1038ce027b2efe43abe -README.zh.md: 85e53445eb149fe55717eec27028f8285823af05 +README.md: f5d01f3988cd188ad8fd58a3e892159d7b5dcb15 +README.zh.md: 5a3c9ccaf9c4b2d388b5d9f728889ff78bfd5bfd diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md index fd4f9793b1..f5d01f3988 100644 --- a/packages/experimental/webworker-packer/README.md +++ b/packages/experimental/webworker-packer/README.md @@ -63,3 +63,5 @@ None; this package neither assembles nor sends a provider request. None. + +**Runtime invariant:** No companion is published. 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. diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md index 85e53445eb..5a3c9ccaf9 100644 --- a/packages/experimental/webworker-packer/README.zh.md +++ b/packages/experimental/webworker-packer/README.zh.md @@ -63,3 +63,5 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为 无。 + +**运行时不变式:** 不发布伴生入口。这是没有生产事件流或可变数据的构建时 pass;无法解析的 own request 与全有或全无 wrapper 约定会直接让 pack 失败。 diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 9fa51d3fe3..b75cae3da8 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -19,16 +19,11 @@ "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", "bin.js", "lib/repository-*.js", @@ -44,12 +39,10 @@ }, "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:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/experimental/webworker-packer/src/invariant.ts b/packages/experimental/webworker-packer/src/invariant.ts deleted file mode 100644 index bfa1060afb..0000000000 --- a/packages/experimental/webworker-packer/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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/tsconfig.json b/packages/experimental/webworker-packer/tsconfig.json index f31531c778..152b9ad8c2 100644 --- a/packages/experimental/webworker-packer/tsconfig.json +++ b/packages/experimental/webworker-packer/tsconfig.json @@ -19,9 +19,6 @@ }, { "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 index b948ddb571..bbcad38379 100644 --- a/packages/experimental/webworker-packer/tsdown.config.ts +++ b/packages/experimental/webworker-packer/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' * 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'], + entry: ['lib/types/index.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 6f293346ac..74f6ba36d3 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-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/experimental/webworker-runtime/README.md -README.md: eaf467b8e4475908d5534113553c51182e30b7f2 -README.zh.md: 879f9a5d2d949296886ed6af1e6bf40bd42540d5 +README.md: 87d53937bee73d7e771ad957ea6c5d953965169f +README.zh.md: d11c074ca02bab12a9f72be17b035389507e05cb diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index eaf467b8e4..87d53937be 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -66,3 +66,5 @@ None; this package neither assembles nor sends a provider request. None. + +**Runtime invariant:** No companion is published. This package is pre-Cordis platform glue — the tree it boots runs the product packages' own invariants, and the assembly's contracts (image contract gate, tunnel refusals) fail loud at boot rather than drifting at run time. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 879f9a5d2d..d11c074ca0 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -66,3 +66,5 @@ kind: "package-library" 无。 + +**运行时不变式:** 不发布伴生入口。这是 Cordis 启动前的平台 glue;其启动的产品树运行各包自己的不变式,image 与 tunnel 约定在 boot 时失败。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 313db801e2..ddd0a03f58 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -16,10 +16,6 @@ "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", @@ -43,8 +39,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-host-webserver": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -54,7 +49,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -69,7 +63,6 @@ }, "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/invariant.ts b/packages/experimental/webworker-runtime/src/invariant.ts deleted file mode 100644 index de56607cb2..0000000000 --- a/packages/experimental/webworker-runtime/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-experimental-webworker-runtime`. - * @module @deepseek-ai/dsh-experimental-webworker-runtime/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-runtime' - -/** Cordis companion plugin name. */ -export const name = 'webworker-runtime-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package is pre-Cordis platform glue — - * the tree it boots runs the product packages' own invariants, and the - * assembly's contracts (image contract gate, tunnel refusals) fail loud at - * boot rather than drifting at run time. - */ -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-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index ab3ef04aee..f28341d750 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../host/webserver" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/crypto" } diff --git a/packages/experimental/webworker-runtime/tsdown.config.ts b/packages/experimental/webworker-runtime/tsdown.config.ts index a55f53f1a0..720bba61b7 100644 --- a/packages/experimental/webworker-runtime/tsdown.config.ts +++ b/packages/experimental/webworker-runtime/tsdown.config.ts @@ -63,17 +63,6 @@ export default defineConfig([{ fixedExtension: false, dts: false, clean: false, -}, { - // The invariant companion ships as its own bundle, like every package, - // from the tsc-emitted artifact plane the root build also consumes. - entry: ['lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'neutral', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, }, { // The worker artifact: the host tree's Node-compatibility layer plus the // assembly, bundled whole — a worker served from a static URL can fetch no diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index 007fa56429..a1dd1d2fee 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/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/extensions/cordis-client-runner/README.md -README.md: ba813e3498752902b80102ecad5802187d95a129 -README.zh.md: 1b975550dc4d08aef90f8e7a79a81a67983dfce1 +README.md: 3b5762786e0f5b00fc80533ca4fa97a3d4cd0a4b +README.zh.md: d68dc218f75e65c9a1787a5e43f018c9cdfa3383 diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index ba813e3498..3b5762786e 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -140,3 +140,5 @@ These limits define where the browser half needs special care. They are current None. + +**Runtime invariant:** No companion is published. The owned relation (a live Plugin's loader entry exists exactly while one Plugin Run ID is live) is browser-only state reachable through the client half's service, which the node-plane companion cannot observe. The relation is asserted by the package's own load/teardown coverage instead. diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index 1b975550dc..d68dc218f7 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -140,3 +140,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。live Plugin 与 Plugin Run ID 的关系只存在于浏览器侧 service,Host 不变式无法观察;包内 load/teardown 测试直接覆盖该关系。 diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 6c3e49abed..6276f8b631 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -18,10 +18,6 @@ "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" @@ -52,7 +48,6 @@ "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -62,14 +57,12 @@ "@deepseek-ai/dsh-client-modules": "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", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/extensions/cordis-client-runner/src/invariant.ts b/packages/extensions/cordis-client-runner/src/invariant.ts deleted file mode 100644 index 9459234286..0000000000 --- a/packages/extensions/cordis-client-runner/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-cordis-client-runner`. - * @module @deepseek-ai/dsh-cordis-client-runner/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-cordis-client-runner' - -/** Cordis companion plugin name. */ -export const name = 'cordis-client-runner-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the owned relation (a live - * Plugin's loader entry exists exactly while one Plugin Run ID is live) is - * browser-only state reachable through the client half's service, which the - * node-plane companion cannot observe. The relation is asserted by the - * package's own load/teardown coverage 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/extensions/cordis-client-runner/tests/plugin.client.spec.ts b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts index 4144d97bd0..520b0e5df8 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -3,14 +3,13 @@ * * Plugin composition account: the dispatch family reaches the runner with its * envelope rpcId, the service face is provided for UI surfaces, a load failure - * always reaches the console, and the fiber owns the runner's teardown. Plus the two plane-level companions: the - * node half's empty apply and the invariant registration. + * always reaches the console, the fiber owns the runner's teardown, and the + * node half remains inert. */ /* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import InvariantService from '@deepseek-ai/dsh-invariants' import type { ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, } from '@deepseek-ai/dsh-api-remotes/client' @@ -20,7 +19,6 @@ import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/cli import type {} from '@deepseek-ai/dsh-api-gateway/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' const PLUGIN = 'dyn-1' as CordisDynamicPluginId @@ -438,19 +436,3 @@ describe('node half', () => { expect(typeof NodeHalf.apply).toBe('function') }) }) - -describe('invariant companion', () => { - it('reserves package ownership with an explained empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - const fiber = ctx.plugin(Invariant) - await fiber - 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(() => { - Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) - }).not.toThrow() - await fiber.dispose() - }) -}) diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index f1ac2c60ca..9bea3fbe7b 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../client/ui-theme" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/extensions/cordis-client-runner/tsdown.config.ts b/packages/extensions/cordis-client-runner/tsdown.config.ts index 9d214029b0..280d5f6cd5 100644 --- a/packages/extensions/cordis-client-runner/tsdown.config.ts +++ b/packages/extensions/cordis-client-runner/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-cordis-client-runner', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-cordis-client-runner', ['lib/types/index.js']) diff --git a/packages/extensions/cordis-host-runner/README.i18n.yaml b/packages/extensions/cordis-host-runner/README.i18n.yaml index 266bc35a25..6d210f7ca7 100644 --- a/packages/extensions/cordis-host-runner/README.i18n.yaml +++ b/packages/extensions/cordis-host-runner/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/extensions/cordis-host-runner/README.md -README.md: ec229d225955718977679b00db522ddcf4a3035e -README.zh.md: 862746798e355d9f5c53fad2b8c69be4d73db3eb +README.md: c9f573557607163103adc0ab7277f3ab569d7ab8 +README.zh.md: ae0b24d66df733cca80bea24f2d2be022254dc80 diff --git a/packages/extensions/cordis-host-runner/README.md b/packages/extensions/cordis-host-runner/README.md index ec229d2259..c9f5735576 100644 --- a/packages/extensions/cordis-host-runner/README.md +++ b/packages/extensions/cordis-host-runner/README.md @@ -142,3 +142,5 @@ These limits define when the runner needs special care. They are current package None. + +**Runtime invariant:** No companion is published. The definition registry is process memory with no event stream to observe, and its one owned relation (a running definition owns a settled host-half fiber and its handler table) is established and unwound inside single awaited verbs, so package tests assert it directly. diff --git a/packages/extensions/cordis-host-runner/README.zh.md b/packages/extensions/cordis-host-runner/README.zh.md index 862746798e..ae0b24d66d 100644 --- a/packages/extensions/cordis-host-runner/README.zh.md +++ b/packages/extensions/cordis-host-runner/README.zh.md @@ -142,3 +142,5 @@ runner 建立在两个分离之上。**注册表与沙箱是同一个服务。** 无。 + +**运行时不变式:** 不发布伴生入口。definition registry 是无事件流的进程内存;运行 definition 与 host-half fiber/handler table 的关系在单个 awaited verb 内建立和释放。 diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 4f49b95c8d..518ad5a332 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -39,7 +35,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/typert.host.js", @@ -56,7 +51,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -68,7 +62,6 @@ "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/extensions/cordis-host-runner/src/invariant.ts b/packages/extensions/cordis-host-runner/src/invariant.ts deleted file mode 100644 index 1e712d5143..0000000000 --- a/packages/extensions/cordis-host-runner/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-cordis-host-runner`. - * @module @deepseek-ai/dsh-cordis-host-runner/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-cordis-host-runner' - -/** Cordis companion plugin name. */ -export const name = 'cordis-host-runner-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the definition registry is process memory with no event - * stream to observe, and its one owned relation (a running definition owns a - * settled host-half fiber and its handler table) is established and unwound - * inside single awaited verbs, so package tests assert it directly. - */ -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/extensions/cordis-host-runner/tsconfig.json b/packages/extensions/cordis-host-runner/tsconfig.json index b14e68a5ec..1c734479ea 100644 --- a/packages/extensions/cordis-host-runner/tsconfig.json +++ b/packages/extensions/cordis-host-runner/tsconfig.json @@ -38,9 +38,6 @@ { "path": "../../llm/llm" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../typert/protocol" } diff --git a/packages/extensions/tool-cordis/README.i18n.yaml b/packages/extensions/tool-cordis/README.i18n.yaml index e74d83953c..f60b58d825 100644 --- a/packages/extensions/tool-cordis/README.i18n.yaml +++ b/packages/extensions/tool-cordis/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/extensions/tool-cordis/README.md -README.md: 9f564044979e17f1db8b6329ecb95cc9b10fb51d -README.zh.md: dd4945391d9e215c6db9eeac6b17184a1e5e7b8a +README.md: 574a98ed7fe75e8af108906d80b2cd0c2e043fe8 +README.zh.md: d48d7539f8724834a2272eb7bed7e9b353f27edc diff --git a/packages/extensions/tool-cordis/README.md b/packages/extensions/tool-cordis/README.md index 9f56404497..574a98ed7f 100644 --- a/packages/extensions/tool-cordis/README.md +++ b/packages/extensions/tool-cordis/README.md @@ -192,3 +192,5 @@ These limits define when the toolset is a poor fit or needs special care. They a None. + +**Runtime invariant:** No companion is published. This model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. diff --git a/packages/extensions/tool-cordis/README.zh.md b/packages/extensions/tool-cordis/README.zh.md index dd4945391d..d48d7539f8 100644 --- a/packages/extensions/tool-cordis/README.zh.md +++ b/packages/extensions/tool-cordis/README.zh.md @@ -192,3 +192,5 @@ Dynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses 无。 + +**运行时不变式:** 不发布伴生入口。这个模型侧 adapter 没有独立 lifecycle stream;执行关系由它调用的 capability seam 负责。 diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 0c1a2c956a..cd3aa2e503 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -18,23 +18,17 @@ "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-agent": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -46,7 +40,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/extensions/tool-cordis/src/invariant.ts b/packages/extensions/tool-cordis/src/invariant.ts deleted file mode 100644 index 3b58f40fd7..0000000000 --- a/packages/extensions/tool-cordis/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`. - * @module @deepseek-ai/dsh-tool-cordis/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-tool-cordis' - -/** Cordis companion plugin name. */ -export const name = 'tool-cordis-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/extensions/tool-cordis/tsconfig.json b/packages/extensions/tool-cordis/tsconfig.json index 85557e1893..3c1ef19fa6 100644 --- a/packages/extensions/tool-cordis/tsconfig.json +++ b/packages/extensions/tool-cordis/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../cordis-host-runner" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/extensions/ui-cordis/README.i18n.yaml b/packages/extensions/ui-cordis/README.i18n.yaml index fc44dfcfb6..b874be6b21 100644 --- a/packages/extensions/ui-cordis/README.i18n.yaml +++ b/packages/extensions/ui-cordis/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/extensions/ui-cordis/README.md -README.md: 715d88a80a7f14425eb8d7f4374b0dad590866f1 -README.zh.md: 4678aea277253e880d82e65549f4036868798c12 +README.md: ceb93b56299ca202da1f8d96ea3b14e1efa2263a +README.zh.md: e56b4d8adff969644370f2599fc63a0308bef90e diff --git a/packages/extensions/ui-cordis/README.md b/packages/extensions/ui-cordis/README.md index 715d88a80a..ceb93b5629 100644 --- a/packages/extensions/ui-cordis/README.md +++ b/packages/extensions/ui-cordis/README.md @@ -126,3 +126,5 @@ These limits define where the surfaces need special care. They are current packa None. + +**Runtime invariant:** No companion is published. A single keyed toolview registration whose disposal is proven by the HMR-safety spec. The one mutable relation this package owns — the per-definition run-state observable — lives in the browser process, out of reach of the host invariant service, and the node half emits no cordis events and holds no cross-plugin state. diff --git a/packages/extensions/ui-cordis/README.zh.md b/packages/extensions/ui-cordis/README.zh.md index 4678aea277..e56b4d8adf 100644 --- a/packages/extensions/ui-cordis/README.zh.md +++ b/packages/extensions/ui-cordis/README.zh.md @@ -126,3 +126,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。插件只注册一个 keyed toolview,HMR 测试覆盖释放;per-definition run-state 只存在于浏览器进程,Host 不变式无法观察。 diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index f37b0a8dc5..5dd46696cc 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -18,10 +18,6 @@ "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" @@ -60,7 +56,6 @@ "@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:^" }, "devDependencies": { @@ -76,14 +71,12 @@ "@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:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" ] diff --git a/packages/extensions/ui-cordis/src/invariant.ts b/packages/extensions/ui-cordis/src/invariant.ts deleted file mode 100644 index abb1da139e..0000000000 --- a/packages/extensions/ui-cordis/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-cordis`. - * @module @deepseek-ai/dsh-client-ui-cordis/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-client-ui-cordis' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-cordis-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a single keyed toolview registration whose disposal is - * proven by the HMR-safety spec. The one mutable relation this package owns — - * the per-definition run-state observable — lives in the browser process, out - * of reach of the host invariant service, and the node half emits no cordis - * events and holds no cross-plugin state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/extensions/ui-cordis/tsconfig.json b/packages/extensions/ui-cordis/tsconfig.json index f2d68dff7b..43626595e5 100644 --- a/packages/extensions/ui-cordis/tsconfig.json +++ b/packages/extensions/ui-cordis/tsconfig.json @@ -43,9 +43,6 @@ }, { "path": "../../client/ui-tool" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/extensions/ui-cordis/tsdown.config.ts b/packages/extensions/ui-cordis/tsdown.config.ts index 341b66e6f1..55b5529cf0 100644 --- a/packages/extensions/ui-cordis/tsdown.config.ts +++ b/packages/extensions/ui-cordis/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-cordis', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-cordis', ['lib/types/index.js']) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index 0ad8e5a145..bc1c579e0e 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 2aeb60e6e8390b7c6664822153f9102a5a7ee473 -README.zh.md: b8461edf7fb65e98c5b73bfa29e8744b7aa24848 +README.md: 92ba5d28eb5602ef063216ca15967495857737f7 +README.zh.md: 338d6884003d39f0379536a0917b0c3322f47574 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 2aeb60e6e8..92ba5d28eb 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -85,7 +85,7 @@ The producer trims the text, rejects empty input, and writes one event into the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `feedback/record` event declaration, `recordFeedback` producer, `/feedback` command registration | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; each event is an independent append-only fact) | +| — | No runtime invariant companion is published; each `feedback/record` is an independent append-only fact with no cross-event or mutable-data relationship. | diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index b8461edf7f..338d688400 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -85,7 +85,7 @@ Web 客户端随附该命令。无头模式、ACP 自动化和 JSON-RPC 不提 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`feedback/record` 事件声明、`recordFeedback` 生产方、`/feedback` 命令注册 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;每个事件都是独立的仅追加事实) | +| — | 不发布运行时不变式伴生入口;每个事件都是独立的仅追加事实。 | diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index dac634d5a6..dffc72d2fc 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -18,22 +18,16 @@ "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-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", @@ -44,7 +38,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts deleted file mode 100644 index 6f4c420ec6..0000000000 --- a/packages/feedback/command-feedback/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-command-feedback`. - * @module @deepseek-ai/dsh-command-feedback/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-command-feedback' - -/** Cordis companion plugin name. */ -export const name = 'command-feedback-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: each `feedback/record` is an independent append-only - * fact with no cross-event or mutable-data relationship. - */ -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/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index f303672b06..27d95e93fa 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../session/session-telemetry" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml index 460a9cd6b2..836a82fcf3 100644 --- a/packages/feedback/message-feedback/README.i18n.yaml +++ b/packages/feedback/message-feedback/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/feedback/message-feedback/README.md -README.md: dc55231d7af3af57fa116d52ddb7ec58ab0a153e -README.zh.md: 4a91880a42a7de6042a8a9be056641f2c07ebb8a +README.md: d8d2746293f516a5fca1729d574646dbd59698b7 +README.zh.md: f6396fe7a20355e17281e47515ebb92a8f880e06 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md index dc55231d7a..d8d2746293 100644 --- a/packages/feedback/message-feedback/README.md +++ b/packages/feedback/message-feedback/README.md @@ -97,7 +97,7 @@ The service fails closed: disposal drains in-flight writes before closing the do | [`src/index.ts`](src/index.ts) | Service class: config validation, per-Session queue, durability barrier, `@Remote` methods | | [`src/types.ts`](src/types.ts) | Public request, value, and failure vocabulary (types only, for generated Remote clients) | | [`src/spec.ts`](src/spec.ts) | Storage-domain declaration: `message_feedback` domain, `sessions` table, row schemas | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the domain schema validates rows on reopen) | +| — | No runtime invariant companion is published; the private typed writer owns current row mutations, the domain schema validates rows on reopen, and no second authority exists. | diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md index 4a91880a42..f6396fe7a2 100644 --- a/packages/feedback/message-feedback/README.zh.md +++ b/packages/feedback/message-feedback/README.zh.md @@ -97,7 +97,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 服务类:配置校验、按 Session 队列、持久性屏障、`@Remote` 方法 | | [`src/types.ts`](src/types.ts) | 公开的请求、值与失败词汇(仅类型,供生成的 Remote 客户端使用) | | [`src/spec.ts`](src/spec.ts) | storage-domain 声明:`message_feedback` 域、`sessions` 表、行 schema | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;域 schema 在重开时校验行) | +| — | 不发布运行时不变式伴生入口;域 schema 在重开时校验行。 | diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 75359357f9..d797cfe130 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -39,7 +35,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/typert.host.js", @@ -50,7 +45,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -66,7 +60,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/feedback/message-feedback/src/invariant.ts b/packages/feedback/message-feedback/src/invariant.ts deleted file mode 100644 index 5433f318f1..0000000000 --- a/packages/feedback/message-feedback/src/invariant.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/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-message-feedback' - -/** Cordis companion plugin name. */ -export const name = 'message-feedback-invariant' -/** Services required before the companion can reserve and check package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the private typed writer owns current row mutations, - * the domain schema validates rows on reopen, and no second authority exists. - */ -const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] }) - -/** - * 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/feedback/message-feedback/tests/invariant.spec.ts b/packages/feedback/message-feedback/tests/invariant.spec.ts deleted file mode 100644 index 3db17b8211..0000000000 --- a/packages/feedback/message-feedback/tests/invariant.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as MessageFeedbackInvariant from '../src/invariant.ts' -import { setupHarness } from './helpers.ts' - -describe('message-feedback invariant companion', () => { - it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => { - const harness = await setupHarness() - try { - await harness.ctx.plugin(InvariantRegistry) - const fiber = await harness.ctx.plugin(MessageFeedbackInvariant) - - expect(() => { - harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {}) - }).toThrow(/already registered/u) - - await fiber.dispose() - await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined() - } finally { - await harness.dispose() - } - }) -}) diff --git a/packages/feedback/message-feedback/tsconfig.json b/packages/feedback/message-feedback/tsconfig.json index 267225ec13..203d78aa99 100644 --- a/packages/feedback/message-feedback/tsconfig.json +++ b/packages/feedback/message-feedback/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../typert/protocol" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 0b5d045a0f..7dcf82e301 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-local/README.md -README.md: a8a4815bf26985f3d34d1a11e69272f5e429e1e3 -README.zh.md: d0a9f357a98ea7c989a92ce3d40ed240fd316b00 +README.md: 3ac8dcab56479add82b3d8d8a774c22022d5687b +README.zh.md: b65055d1bd7af5ce629cb376e8f83205b96dccde diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index a8a4815bf2..3ac8dcab56 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -144,3 +144,5 @@ These limits define when the local backend is a poor fit or needs special operat None. + +**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index d0a9f357a9..b65055d1bd 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -144,3 +144,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index b267d2da82..887a6da883 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -18,22 +18,16 @@ "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-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -42,7 +36,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts deleted file mode 100644 index 07a8a62246..0000000000 --- a/packages/fs/fs-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-fs-local`. - * @module @deepseek-ai/dsh-fs-local/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-fs-local' - -/** Cordis companion plugin name. */ -export const name = 'fs-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json index 470a9b12d8..3f14e43d35 100644 --- a/packages/fs/fs-local/tsconfig.json +++ b/packages/fs/fs-local/tsconfig.json @@ -20,9 +20,6 @@ }, { "path": "../fs" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/fs/fs-observation-policy/README.i18n.yaml b/packages/fs/fs-observation-policy/README.i18n.yaml index 4639de04ba..da076a74ed 100644 --- a/packages/fs/fs-observation-policy/README.i18n.yaml +++ b/packages/fs/fs-observation-policy/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/fs-observation-policy/README.md -README.md: 37bdcca67976b80851daf199f62a8227e43154e5 -README.zh.md: 7e092b91e1ba76a55a897fde551271d1b34e6ada +README.md: 45534acf97f0a8c90172846cb7bc6221caa7a365 +README.zh.md: 72034458c67dfa6c64981167de7cf4cdace3c033 diff --git a/packages/fs/fs-observation-policy/README.md b/packages/fs/fs-observation-policy/README.md index 37bdcca679..45534acf97 100644 --- a/packages/fs/fs-observation-policy/README.md +++ b/packages/fs/fs-observation-policy/README.md @@ -137,3 +137,5 @@ These limits define when the policy is a poor fit or needs special operational c None. + +**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/fs/fs-observation-policy/README.zh.md b/packages/fs/fs-observation-policy/README.zh.md index 7e092b91e1..72034458c6 100644 --- a/packages/fs/fs-observation-policy/README.zh.md +++ b/packages/fs/fs-observation-policy/README.zh.md @@ -137,3 +137,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 98d7d9d907..1e0c6a92c7 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -18,27 +18,20 @@ "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-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/fs/fs-observation-policy/src/invariant.ts b/packages/fs/fs-observation-policy/src/invariant.ts deleted file mode 100644 index 537eb70b67..0000000000 --- a/packages/fs/fs-observation-policy/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-fs-observation-policy`. - * @module @deepseek-ai/dsh-fs-observation-policy/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-fs-observation-policy' - -/** Cordis companion plugin name. */ -export const name = 'fs-observation-policy-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/fs/fs-observation-policy/tsconfig.json b/packages/fs/fs-observation-policy/tsconfig.json index d2c7cffe4b..96568accbc 100644 --- a/packages/fs/fs-observation-policy/tsconfig.json +++ b/packages/fs/fs-observation-policy/tsconfig.json @@ -17,9 +17,6 @@ }, { "path": "../fs" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index bf7f93e868..2cfd4f89f2 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/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/fs-sandbox/README.md -README.md: 722edc6230d972edbf31c3e987600dcd3dbf3102 -README.zh.md: ea77acb4fe4190674a292fef90dec7f8a936d7d3 +README.md: 8308c92f980c64d048e6f289af6d1c4d12cb6fc9 +README.zh.md: 1f43fc0f2f136eebb45aea2e546fb0e3ac65e5bc diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index 722edc6230..8308c92f98 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -133,3 +133,5 @@ These limits define when the sandbox backend is a poor fit or needs special oper None. + +**Runtime invariant:** No companion is published. This stateless adapter delegates policy and filesystem relations to their owning seams. diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index ea77acb4fe..1f43fc0f2f 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -133,3 +133,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。这个无状态 adapter 把 policy 与 filesystem 关系委托给其所属 seam。 diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 07751b0837..5a8276caed 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -18,23 +18,17 @@ "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-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -42,7 +36,6 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/fs/fs-sandbox/src/invariant.ts b/packages/fs/fs-sandbox/src/invariant.ts deleted file mode 100644 index 8a42c86c90..0000000000 --- a/packages/fs/fs-sandbox/src/invariant.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-fs-sandbox`. - * @module @deepseek-ai/dsh-fs-sandbox/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-fs-sandbox' - -/** Cordis companion plugin name. */ -export const name = 'fs-sandbox-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** No runtime invariant: this stateless adapter delegates policy and filesystem relations to their owning seams. */ -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/fs/fs-sandbox/tsconfig.json b/packages/fs/fs-sandbox/tsconfig.json index d85088a820..c9e2f629d5 100644 --- a/packages/fs/fs-sandbox/tsconfig.json +++ b/packages/fs/fs-sandbox/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../sandbox/sandbox-policy" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 506f26e265..27f1e054d8 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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-search/README.md -README.md: 3244108e99e71aaf2c89121ae90cd4e9126fa9f2 -README.zh.md: dbbea8e57490800baa4265a6409be0260802c5fb +README.md: 1b94b76cc4ee6e9caccb3899f504d0eed6739443 +README.zh.md: e42dc43ac795312038ac7ec5ee8a7aa1c9cfbecd diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 3244108e99..1b94b76cc4 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -225,3 +225,5 @@ These limits define when the search tools are a poor fit or need special operati None. + +**Runtime invariant:** No companion is published. This model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index dbbea8e574..e42dc43ac7 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -225,3 +225,5 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g 无。 + +**运行时不变式:** 不发布伴生入口。这个模型侧 adapter 没有独立 lifecycle stream;执行关系由它调用的 capability seam 负责。 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index fb86a437de..731ed01f2e 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -18,16 +18,11 @@ "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", @@ -36,7 +31,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -51,7 +45,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts deleted file mode 100644 index 26d14775ba..0000000000 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-fs-search`. - * @module @deepseek-ai/dsh-tool-fs-search/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-tool-fs-search' - -/** Cordis companion plugin name. */ -export const name = 'tool-fs-search-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/fs/tool-fs-search/tsconfig.json b/packages/fs/tool-fs-search/tsconfig.json index c53a3c3965..85d425aa7f 100644 --- a/packages/fs/tool-fs-search/tsconfig.json +++ b/packages/fs/tool-fs-search/tsconfig.json @@ -38,9 +38,6 @@ }, { "path": "../../util/timeout" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index cd2106cbbc..db3609a7c4 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: 193f842aa76b7d3f4df717d1d23960ef4d3ee329 -README.zh.md: 3cbd6bec6d9a4b200d90c15b32291cacc2e41aa8 +README.md: 33703f09a02cb32c7f5e55a890f5ce9bb7b311c3 +README.zh.md: e96ad544d03dc58ecf8945c0c11b1d13983fe3a2 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 193f842aa7..33703f09a0 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -252,3 +252,5 @@ These limits define when the tool suite is a poor fit or needs special operation None. + +**Runtime invariant:** No companion is published. This model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 3cbd6bec6d..e96ad544d0 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -252,3 +252,5 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 无。 + +**运行时不变式:** 不发布伴生入口。这个模型侧 adapter 没有独立 lifecycle stream;执行关系由它调用的 capability seam 负责。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 64d2c88d36..d13f35ab99 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -18,16 +18,11 @@ "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", @@ -38,7 +33,6 @@ "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -56,7 +50,6 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts deleted file mode 100644 index 8cbbe4f423..0000000000 --- a/packages/fs/tool-fs/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-fs`. - * @module @deepseek-ai/dsh-tool-fs/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-tool-fs' - -/** Cordis companion plugin name. */ -export const name = 'tool-fs-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index a852829079..5ae5f31375 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -30,9 +30,6 @@ { "path": "../fs-observation-policy" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index d412398518..d753b957de 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/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-str-replace-editor/README.md -README.md: 950ad54b5724a25975b250c3f3e61daa834bec1f -README.zh.md: 97a5f22cc2563d3fed234971f18a301abd28ad5c +README.md: 66663b2ab19585c30f00e6e6d91ce759b4fb8d81 +README.zh.md: 60c63b25cb5c5960aaafcf4f91b3ce23e8911cd7 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 950ad54b57..66663b2ab1 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -145,3 +145,5 @@ These limits define when the editor tool is a poor fit or needs special operatio None. + +**Runtime invariant:** No companion is published. The tool adapter owns no independent durable state; filesystem mutation relations stay with the provider and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index 97a5f22cc2..60c63b25cb 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -145,3 +145,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。tool adapter 不持有独立持久状态;filesystem mutation 关系属于 provider 与 policy 插件。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index c97dd886af..678b96270c 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -18,21 +18,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -47,7 +41,6 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts deleted file mode 100644 index c2b6d8f149..0000000000 --- a/packages/fs/tool-str-replace-editor/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`. - * @module @deepseek-ai/dsh-tool-str-replace-editor/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-tool-str-replace-editor' - -/** Cordis companion plugin name. */ -export const name = 'tool-str-replace-editor-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the tool adapter owns no independent durable state; - * filesystem mutation relations stay with the provider and policy plugins. - */ -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/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json index 408fab58fd..b9dba81d5d 100644 --- a/packages/fs/tool-str-replace-editor/tsconfig.json +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -7,10 +7,10 @@ "include": ["src"], "references": [ { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../core/tools" }, { "path": "../fs" }, { "path": "../../sandbox/sandbox" }, - { "path": "../../sandbox/sandbox-policy" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../../sandbox/sandbox-policy" } ] } diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 80d925f56d..afecbbb3f6 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/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/goal/command-goal/README.md -README.md: bb5aa406b0269e9fbedd1e85d557ac17640e3e73 -README.zh.md: 45864aa93abcd81584b27eff859bbcdd7b22a1f6 +README.md: 92a7ba9df3ab8e65899f759b111fb2fea5b8373f +README.zh.md: bf36818000dfce3b47e2b96bf2e62e4e77b7e97f diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index bb5aa406b0..92a7ba9df3 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -84,7 +84,7 @@ This section explains how the command parses input and renders output; the obser | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: command grammar, status rendering, attachment submission | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: empty (no runtime invariant — the goal domain owns accepted mutations) | +| — | No runtime invariant companion is published; this command adapter owns no event stream or state projection; accepted mutations are checked by the goal domain and command dispatch behavior is covered by package tests. | diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index 45864aa93a..bf36818000 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:命令语法、状态渲染、附件提交 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生:空(无运行时不变式——已接受的变更由 goal 领域负责) | +| — | 不发布运行时不变式伴生入口;已接受的变更由 goal 领域负责。 | diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 7f0acdca81..eba28c3d13 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -18,23 +18,17 @@ "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-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -43,7 +37,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/goal/command-goal/src/invariant.ts b/packages/goal/command-goal/src/invariant.ts deleted file mode 100644 index 673d6a27ef..0000000000 --- a/packages/goal/command-goal/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-command-goal`. - * @module @deepseek-ai/dsh-command-goal/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-command-goal' - -/** Cordis companion plugin name. */ -export const name = 'command-goal-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this command adapter owns no event stream or state projection; accepted - * mutations are checked by the goal domain and command dispatch behavior is covered by package tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/goal/command-goal/tsconfig.json b/packages/goal/command-goal/tsconfig.json index bfd4de65c5..d6c8492d45 100644 --- a/packages/goal/command-goal/tsconfig.json +++ b/packages/goal/command-goal/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../goal" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 330e2036e7..1bee722d66 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/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/goal/tool-goal/README.md -README.md: a7016374d8de11d96db963a341e21f635b09ca3b -README.zh.md: 8353f8905f007f58d30d26b487e283896175a106 +README.md: 4a63c19352bceb9776a3d2cec003db8d234f6b43 +README.zh.md: b44fcabc5567436348130b0456bc6103a6466d6f diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index a7016374d8..4a63c19352 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -80,7 +80,7 @@ This section explains how the tools enforce authority and render output; the obs | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, config, system-prompt section, result rendering | | [`src/authority.ts`](src/authority.ts) | Execution-time authority checks and goal-round acceptance | | [`src/wrapup.ts`](src/wrapup.ts) | Closing-message instruction for terminal autonomous updates | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: empty (no runtime invariant — the goal domain owns accepted mutations) | +| — | No runtime invariant companion is published; this model-facing adapter owns no independent state or event protocol; accepted mutations are checked by the goal domain and authority behavior is package-tested. | ### Tool output diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 8353f8905f..b44fcabc55 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -80,7 +80,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、配置、系统提示词章节、结果渲染 | | [`src/authority.ts`](src/authority.ts) | 执行时权限检查与 Goal Round 接受 | | [`src/wrapup.ts`](src/wrapup.ts) | 终局自主更新的结束消息指令 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生:空(无运行时不变式——已接受的变更由 goal 领域负责) | +| — | 不发布运行时不变式伴生入口;已接受的变更由 goal 领域负责。 | ### 工具输出 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index cfea9e6154..53a6871f3f 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -18,23 +18,17 @@ "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-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -50,7 +44,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/goal/tool-goal/src/invariant.ts b/packages/goal/tool-goal/src/invariant.ts deleted file mode 100644 index 28b9637772..0000000000 --- a/packages/goal/tool-goal/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-goal`. - * @module @deepseek-ai/dsh-tool-goal/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-tool-goal' - -/** Cordis companion plugin name. */ -export const name = 'tool-goal-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter owns no independent state or event protocol; - * accepted mutations are checked by the goal domain and authority behavior is package-tested. - */ -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/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json index 1933a0c8b3..fc97dd09aa 100644 --- a/packages/goal/tool-goal/tsconfig.json +++ b/packages/goal/tool-goal/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../goal" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../session/session-projection" } diff --git a/packages/guard/repeat-tool-reminder/README.i18n.yaml b/packages/guard/repeat-tool-reminder/README.i18n.yaml index 600fcd0842..b5390739fb 100644 --- a/packages/guard/repeat-tool-reminder/README.i18n.yaml +++ b/packages/guard/repeat-tool-reminder/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/guard/repeat-tool-reminder/README.md -README.md: 94d6ac6d3805c499a53473dc5c1aa1a290026fa2 -README.zh.md: d4f7253cd720da9e91b403392ccc9a5b44eb6aa3 +README.md: 9b3f176e3c84278b454a138f661e3803d31457e1 +README.zh.md: f2678fe8c3a7a6412681405bb026c21e24c088bd diff --git a/packages/guard/repeat-tool-reminder/README.md b/packages/guard/repeat-tool-reminder/README.md index 94d6ac6d38..9b3f176e3c 100644 --- a/packages/guard/repeat-tool-reminder/README.md +++ b/packages/guard/repeat-tool-reminder/README.md @@ -95,7 +95,7 @@ Reminders ride the post-execute decision's `additionalContexts` (source `{kind: | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, fail-loud validation, chain listeners | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant: the chain is private to one post-execute listener) | +| — | No runtime invariant companion is published; the repeat chain is private to one post-execute listener and exposes no package-owned event or snapshot that an independent companion can observe. | diff --git a/packages/guard/repeat-tool-reminder/README.zh.md b/packages/guard/repeat-tool-reminder/README.zh.md index d4f7253cd7..f2678fe8c3 100644 --- a/packages/guard/repeat-tool-reminder/README.zh.md +++ b/packages/guard/repeat-tool-reminder/README.zh.md @@ -95,7 +95,7 @@ guard 建立在四项承诺之上: | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、快速失败校验、链监听器 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式:链私有于一个 post-execute 监听器) | +| — | 不发布运行时不变式伴生入口;链私有于一个 post-execute 监听器。 | diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index bf668f9651..fa268eae4e 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -18,16 +18,11 @@ "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", @@ -36,7 +31,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -44,7 +38,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/guard/repeat-tool-reminder/src/invariant.ts b/packages/guard/repeat-tool-reminder/src/invariant.ts deleted file mode 100644 index d01fd60179..0000000000 --- a/packages/guard/repeat-tool-reminder/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-repeat-tool-reminder`. - * @module @deepseek-ai/dsh-repeat-tool-reminder/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-repeat-tool-reminder' - -/** Cordis companion plugin name. */ -export const name = 'repeat-tool-reminder-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the repeat chain is private to one post-execute listener and exposes no - * package-owned event or snapshot that an independent companion can observe. - */ -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/guard/repeat-tool-reminder/tsconfig.json b/packages/guard/repeat-tool-reminder/tsconfig.json index 7708c54a1c..66439bcd5f 100644 --- a/packages/guard/repeat-tool-reminder/tsconfig.json +++ b/packages/guard/repeat-tool-reminder/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../llm/llm" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/guard/timeout-policy/README.i18n.yaml b/packages/guard/timeout-policy/README.i18n.yaml index 0f84a0f25a..3ed678e71b 100644 --- a/packages/guard/timeout-policy/README.i18n.yaml +++ b/packages/guard/timeout-policy/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/guard/timeout-policy/README.md -README.md: 6e6d403e19a35eecd13442c4da0f2fa5d44e0365 -README.zh.md: c3941d14ae3144b383aba38ccbaa1aedd6594f4e +README.md: e1efc14c55dc01d058d85849bbe28bd87a6993cb +README.zh.md: 35ed0f64fa504327dac1b49ae2510c66525ecd1a diff --git a/packages/guard/timeout-policy/README.md b/packages/guard/timeout-policy/README.md index 6e6d403e19..e1efc14c55 100644 --- a/packages/guard/timeout-policy/README.md +++ b/packages/guard/timeout-policy/README.md @@ -77,7 +77,7 @@ Multiple `tools/execute` listeners compose by Cordis registration order, which c | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `TOOL_TIMEOUT`, `name`/`inject`/`apply`, the `tools/execute` wrapper | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant: the stateless wrapper owns no package-local event history) | +| — | No runtime invariant companion is published; this stateless policy plugin owns no package-local event history or mutable data relation beyond the seam it intercepts. | diff --git a/packages/guard/timeout-policy/README.zh.md b/packages/guard/timeout-policy/README.zh.md index c3941d14ae..35ed0f64fa 100644 --- a/packages/guard/timeout-policy/README.zh.md +++ b/packages/guard/timeout-policy/README.zh.md @@ -77,7 +77,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`TOOL_TIMEOUT`、`name`/`inject`/`apply`、`tools/execute` 包装层 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式:无状态包装层不拥有包级事件历史) | +| — | 不发布运行时不变式伴生入口;无状态包装层不拥有包级事件历史。 | diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 3424ff6c92..c5a75c55d7 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -18,28 +18,21 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/guard/timeout-policy/src/invariant.ts b/packages/guard/timeout-policy/src/invariant.ts deleted file mode 100644 index 1ad88ffa6e..0000000000 --- a/packages/guard/timeout-policy/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-call-timeout-policy`. - * @module @deepseek-ai/dsh-tool-call-timeout-policy/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-tool-call-timeout-policy' - -/** Cordis companion plugin name. */ -export const name = 'timeout-policy-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this stateless policy plugin owns no package-local event history or mutable - * data relation beyond the seam it intercepts. - */ -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/guard/timeout-policy/tsconfig.json b/packages/guard/timeout-policy/tsconfig.json index 5ed857bfd8..737388623f 100644 --- a/packages/guard/timeout-policy/tsconfig.json +++ b/packages/guard/timeout-policy/tsconfig.json @@ -23,9 +23,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/hooks/hooks-claude-code/README.i18n.yaml b/packages/hooks/hooks-claude-code/README.i18n.yaml index 1fadb002e3..28a6b22cd9 100644 --- a/packages/hooks/hooks-claude-code/README.i18n.yaml +++ b/packages/hooks/hooks-claude-code/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/hooks/hooks-claude-code/README.md -README.md: cb674dcf7d64aa061adfe7b9e18626e7be7be4a5 -README.zh.md: 81f365c235f36f893b942dc6da0484905f0ae028 +README.md: b90e33a840370215aeb9a0d0f60bd0a0def9adbc +README.zh.md: a9c183b293b388129e0663bac6c1031312dcb327 diff --git a/packages/hooks/hooks-claude-code/README.md b/packages/hooks/hooks-claude-code/README.md index cb674dcf7d..b90e33a840 100644 --- a/packages/hooks/hooks-claude-code/README.md +++ b/packages/hooks/hooks-claude-code/README.md @@ -114,7 +114,7 @@ The [hook-bridges Agent Note](../../../.agents/notes/implemented/feature/2026-06 |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: config validation, listener registration, per-event payloads, decision mapping | | [`src/config.ts`](src/config.ts) | Claude Code config parsing: supported events, matcher validation, command substitution | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the `hook/*` pairing checks live in `dsh-hook-protocol`) | +| — | No runtime invariant companion is published; this bridge publishes hook-protocol session events, whose companion owns which invocation event each result cites. | diff --git a/packages/hooks/hooks-claude-code/README.zh.md b/packages/hooks/hooks-claude-code/README.zh.md index 81f365c235..a9c183b293 100644 --- a/packages/hooks/hooks-claude-code/README.zh.md +++ b/packages/hooks/hooks-claude-code/README.zh.md @@ -114,7 +114,7 @@ matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)、会话源( |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:配置校验、监听器注册、逐事件 payload、决策映射 | | [`src/config.ts`](src/config.ts) | Claude Code 配置解析:受支持事件、matcher 校验、命令替换 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;`hook/*` 配对检查位于 `dsh-hook-protocol`) | +| — | 不发布运行时不变式伴生入口;`hook/*` 配对检查位于 `dsh-hook-protocol`。 | diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index c8bf01077b..743848e9ed 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -18,16 +18,11 @@ "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", @@ -37,7 +32,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -54,7 +48,6 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/hooks/hooks-claude-code/src/invariant.ts b/packages/hooks/hooks-claude-code/src/invariant.ts deleted file mode 100644 index 78ce56ae3e..0000000000 --- a/packages/hooks/hooks-claude-code/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-hooks-claude-code`. - * @module @deepseek-ai/dsh-hooks-claude-code/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-hooks-claude-code' - -/** Cordis companion plugin name. */ -export const name = 'hooks-claude-code-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns - * which invocation event each result cites. - */ -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/hooks/hooks-claude-code/tsconfig.json b/packages/hooks/hooks-claude-code/tsconfig.json index dc2f62776f..23a293d40e 100644 --- a/packages/hooks/hooks-claude-code/tsconfig.json +++ b/packages/hooks/hooks-claude-code/tsconfig.json @@ -40,9 +40,6 @@ }, { "path": "../../shell/shell" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index e3e0d43801..1c241537fb 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/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/hooks/hooks-codex/README.md -README.md: 40729b62eba46bc1926000117e653cd2d260358f -README.zh.md: ed536c364ca1f9fb45a103fb83a3faebabb55ff8 +README.md: 9d2a2d5aff7d97521178725e5ef7ec44fce20330 +README.zh.md: 4e672e37caa42a2dcabbefe4c71549820cee9bc2 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 40729b62eb..9d2a2d5aff 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -110,7 +110,7 @@ The [hook-bridges Agent Note](../../../.agents/notes/implemented/feature/2026-06 |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: config validation, listener registration, per-event payloads, decision mapping | | [`src/config.ts`](src/config.ts) | Codex config parsing: the five supported events, matcher validation, skip reasons | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the `hook/*` pairing checks live in `dsh-hook-protocol`) | +| — | No runtime invariant companion is published; this bridge publishes hook-protocol session events, whose companion owns which invocation event each result cites. | diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index ed536c364c..4e672e37ca 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -110,7 +110,7 @@ matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)或会话源( |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:配置校验、监听器注册、逐事件 payload、决策映射 | | [`src/config.ts`](src/config.ts) | Codex 配置解析:五个受支持事件、matcher 校验、跳过原因 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;`hook/*` 配对检查位于 `dsh-hook-protocol`) | +| — | 不发布运行时不变式伴生入口;`hook/*` 配对检查位于 `dsh-hook-protocol`。 | diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index b267130054..3f1a619a3e 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -18,16 +18,11 @@ "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", @@ -37,7 +32,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -53,7 +47,6 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts deleted file mode 100644 index 5d19efb7b0..0000000000 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-hooks-codex`. - * @module @deepseek-ai/dsh-hooks-codex/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-hooks-codex' - -/** Cordis companion plugin name. */ -export const name = 'hooks-codex-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns - * which invocation event each result cites. - */ -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/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json index f4a015f207..7b87f4d871 100644 --- a/packages/hooks/hooks-codex/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../shell/shell" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 0499cc3d2f..54512072a4 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/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/host/directory-picker-auto/README.md -README.md: b42c4c159206cbd4583326dfbeaac4b459c6c230 -README.zh.md: 56928d29658b4cd312a3f0d144a6a0ad39fc7c30 +README.md: e6ac8b1cebe1ae55e4477868c94fb82ef8f16e86 +README.zh.md: d90a7d62ec03d5688ad03e883879f384cdcfc213 diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index b42c4c1592..e6ac8b1ceb 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -118,3 +118,5 @@ These limits define when the boot-time sample can misjudge the host. They are cu None. + +**Runtime invariant:** No companion is published. The sole effect is one boot-time Loader-entry mount owned by the plugin fiber; the store is authoritative. diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 56928d2965..d90a7d62ec 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -118,3 +118,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。唯一 effect 是由插件 fiber 持有的 boot-time Loader-entry mount,store 是权威来源。 diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index ffc7337a0b..9eacbae1b4 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -18,16 +18,11 @@ "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", @@ -38,8 +33,7 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-host-webserver": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -50,7 +44,6 @@ "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-host-webserver": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/src/invariant.ts b/packages/host/directory-picker-auto/src/invariant.ts deleted file mode 100644 index c31102a752..0000000000 --- a/packages/host/directory-picker-auto/src/invariant.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Package-owned invariant companion for the adaptive directory-picker chooser. - * @module @deepseek-ai/dsh-host-directory-picker-auto/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto' - -/** Cordis companion plugin name. */ -export const name = 'host-directory-picker-auto-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: the sole effect is one boot-time Loader-entry mount owned by the plugin fiber; the store is authoritative. */ -const install: InvariantInstaller = () => {} - -/** - * Register the adaptive directory-picker 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)) diff --git a/packages/host/directory-picker-auto/tsconfig.json b/packages/host/directory-picker-auto/tsconfig.json index e9c8c688a0..971363c2ed 100644 --- a/packages/host/directory-picker-auto/tsconfig.json +++ b/packages/host/directory-picker-auto/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../webserver" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index a9184c9313..5a8cc025b8 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: 986504a48da5b1ea28c174168eec41e40bb77e4e -README.zh.md: 54a722bdad2d88e6cadc6f39c74f479851197351 +README.md: 33cf379b9ccf4d08355ef4204aa9e2f639e849df +README.zh.md: 28a29de15c255cc09008991e1df71e26c90e3f70 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 986504a48d..33cf379b9c 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -72,7 +72,7 @@ Every filesystem await races the caller's signal (`raceAbort`), so a stalled net | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `BrowseDirectoryPicker` service: listing, creation, bounded window, error mapping | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the filesystem is authoritative) | +| — | No runtime invariant companion is published; each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. | diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 54a722bdad..28a29de15c 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `BrowseDirectoryPicker` 服务:列举、创建、有界窗口、错误映射 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;文件系统是权威) | +| — | 不发布运行时不变式伴生入口;文件系统是权威。 | diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 48da08f3c8..8172457ecf 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -18,16 +18,11 @@ "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", @@ -36,11 +31,9 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-browse/src/invariant.ts b/packages/host/directory-picker-browse/src/invariant.ts deleted file mode 100644 index 7170de7809..0000000000 --- a/packages/host/directory-picker-browse/src/invariant.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Package-owned invariant companion for the browse directory-picker backend. - * @module @deepseek-ai/dsh-host-directory-picker-browse/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse' - -/** Cordis companion plugin name. */ -export const name = 'host-directory-picker-browse-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. */ -const install: InvariantInstaller = () => {} - -/** - * Register the browse directory-picker 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)) diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json index 5196c3cab6..7d800936ac 100644 --- a/packages/host/directory-picker-browse/tsconfig.json +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -12,10 +12,13 @@ ], "references": [ { - "path": "../directory-picker" + "path": "../../../vendor/cordis" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../../vendor/schemastery" + }, + { + "path": "../directory-picker" } ] } diff --git a/packages/host/directory-picker-browse/tsdown.config.ts b/packages/host/directory-picker-browse/tsdown.config.ts index 388cecbcb5..4f3c8b4b78 100644 --- a/packages/host/directory-picker-browse/tsdown.config.ts +++ b/packages/host/directory-picker-browse/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown' /** Node-only backend: listing and creation primitives over the host filesystem. */ export default defineConfig([ { - entry: ['lib/types/index.js', 'lib/types/invariant.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index aa59d82312..e49c5d0c9a 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: 420ec71899366733c9be538ca5900644e81b668d -README.zh.md: a6671d2f93fbc605bfa74d73e80d835cb0c7824e +README.md: 9c1fb7d742fcd5481aa6ee30f2de3d9e670c09d5 +README.zh.md: 4b47358cdc0c787eefccd2d330c1055dd6278453 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 420ec71899..9c1fb7d742 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -108,3 +108,5 @@ These limits define when the native interaction is unavailable or fragile. They None. + +**Runtime invariant:** No companion is published. Each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index a6671d2f93..4b47358cdc 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -108,3 +108,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。每次 pick 都是一次无状态 subprocess 往返,chooser outcome 只存在于返回路径。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 341fb22e75..903a9ca8b2 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./worker": { "types": "./lib/types/win32-dialog-worker.d.ts", "default": "./lib/worker.cjs" @@ -31,7 +27,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts" ], @@ -42,11 +37,9 @@ "koffi": "^3.1.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "tsx": "^4.19.2" } diff --git a/packages/host/directory-picker-native/src/invariant.ts b/packages/host/directory-picker-native/src/invariant.ts deleted file mode 100644 index 41b77ddb1c..0000000000 --- a/packages/host/directory-picker-native/src/invariant.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Package-owned invariant companion for the native directory-picker backend. - * @module @deepseek-ai/dsh-host-directory-picker-native/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native' - -/** Cordis companion plugin name. */ -export const name = 'host-directory-picker-native-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. */ -const install: InvariantInstaller = () => {} - -/** - * Register the native directory-picker 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)) diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json index 5c8df8f960..240da9aae6 100644 --- a/packages/host/directory-picker-native/tsconfig.json +++ b/packages/host/directory-picker-native/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../directory-picker" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/native-command" } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index b20772339d..17ac337381 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js', 'lib/types/invariant.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index d2d9647738..65e4c0188b 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/host/directory-picker/README.md -README.md: bcd2c4dbfa2848e669edbc9f0ebba93804df622c -README.zh.md: 8604b54c99e88cedfedf820e3dd0f8800f9c5972 +README.md: 93edf851d507eedce13daf5b274a715b4662c3d5 +README.zh.md: 2f4cfb233ea38d23826befbbf8c101a07b5f632b diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index bcd2c4dbfa..93edf851d5 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -109,3 +109,5 @@ These limits define when the seam contract leaves a decision to a future consume None. + +**Runtime invariant:** No companion is published. This stateless Service Definition owns the capability vocabulary, while backends and the Remote controller own observations. diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 8604b54c99..2f4cfb233e 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -109,3 +109,5 @@ web GUI 宿主通过一份约定让操作者选择工作区目录:一个只提 无。 + +**运行时不变式:** 不发布伴生入口。这个无状态 Service Definition 只定义 capability vocabulary,观察由 backend 与 Remote controller 负责。 diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index d6fc2a352e..f1e213dcba 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,17 +27,14 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts deleted file mode 100644 index 5c44aac9af..0000000000 --- a/packages/host/directory-picker/src/invariant.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker' - -/** Cordis companion plugin name. */ -export const name = 'host-directory-picker-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this stateless Service Definition owns the capability - * vocabulary, while backends and the Remote controller own observations. - */ -const install: InvariantInstaller = () => {} - -/** - * Register the directory-picker 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)) diff --git a/packages/host/directory-picker/tsconfig.json b/packages/host/directory-picker/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/host/directory-picker/tsconfig.json +++ b/packages/host/directory-picker/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 24b44fc54c..cd2094611b 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/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/host/frontend-static/README.md -README.md: e80dd44220ec9c29625581a8b833672834523720 -README.zh.md: f3014deacca04f88f1750e5912f84d32562e4ba5 +README.md: 9d959a1031f7d4f8e7fec051504ad5773b33e618 +README.zh.md: 85ebaa7729541a61145dfe3b73cdaebfd6e11855 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index e80dd44220..9d959a1031 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -113,3 +113,5 @@ These limits define when a served asset class is not yet covered. They are curre None. + +**Runtime invariant:** No companion is published. The only owned relation is the single fallback seat, which cannot be probed from the teardown stream — `internal/plugin` fires before the disposing fiber's effects run, so the legitimate owner still holds the seat at notification time and any claim probe would false-positive on every correct disposal (unlike the webserver companion, whose reserved-path probes never collide with a live registration). The seat's register/release symmetry is covered by the package's real-composition HMR-safety test instead. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index f3014deacc..85ebaa7729 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -113,3 +113,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。唯一关系是单个 fallback seat,但 teardown event 在 disposer 前发出,运行时探测会误报;register/release 对称性由真实组合的 HMR 测试覆盖。 diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 97161a2b70..1322d75536 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -18,22 +18,16 @@ "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-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -45,7 +39,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/frontend-static/src/invariant.ts b/packages/host/frontend-static/src/invariant.ts deleted file mode 100644 index f4460b983f..0000000000 --- a/packages/host/frontend-static/src/invariant.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-frontend-static`. - * @module @deepseek-ai/dsh-host-frontend-static/invariant - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-frontend-static' - -/** Cordis companion plugin name. */ -export const name = 'host-frontend-static-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the only owned relation is the single fallback seat, - * which cannot be probed from the teardown stream — `internal/plugin` fires - * before the disposing fiber's effects run, so the legitimate owner still - * holds the seat at notification time and any claim probe would - * false-positive on every correct disposal (unlike the webserver companion, - * whose reserved-path probes never collide with a live registration). The - * seat's register/release symmetry is covered by the package's - * real-composition HMR-safety test 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)) diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json index 5eda998b9b..e28436fb20 100644 --- a/packages/host/frontend-static/tsconfig.json +++ b/packages/host/frontend-static/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../webserver" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 93797f6893..9b350a07a5 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/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/host/plugin-inventory/README.md -README.md: 3f982ebcfdc85f3abd81d1615efccbec6b6bbbed -README.zh.md: eab5409c230ce64f2da66e267c0e203b709daa91 +README.md: 33a53eed46ffabffab61b1867253937a0a82ebea +README.zh.md: 73afbdd51d86ca8143530ed9c66ac1ac23957841 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 3f982ebcfd..33a53eed46 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -57,7 +57,7 @@ Fiber states map onto the public phase vocabulary, with `disposed` folding into |---|---| | [`src/index.ts`](src/index.ts) | `PluginInventoryGateway`: the `pluginInventory` Remote service and the Loader projection | | [`src/types.ts`](src/types.ts) | Public payload types: `PluginInventoryEntry`, `PluginInventorySnapshot`, `PluginFiberPhase` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; every snapshot projects Loader-owned state) | +| — | No runtime invariant companion is published; every snapshot is projected directly from Loader-owned state. | Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index eab5409c23..73afbdd51d 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -57,7 +57,7 @@ Fiber 状态映射到公共阶段词汇,其中 `disposed` 折叠为 `null`— |---|---| | [`src/index.ts`](src/index.ts) | `PluginInventoryGateway`:`pluginInventory` Remote 服务与 Loader 投影 | | [`src/types.ts`](src/types.ts) | 公共 payload 类型:`PluginInventoryEntry`、`PluginInventorySnapshot`、`PluginFiberPhase` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;每个快照都投影 Loader 持有的状态) | +| — | 不发布运行时不变式伴生入口;每个快照都投影 Loader 持有的状态。 | Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index e9db804471..c505f022f7 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -39,7 +35,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/typert.host.js", @@ -54,14 +49,12 @@ "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/host/plugin-inventory/src/invariant.ts b/packages/host/plugin-inventory/src/invariant.ts deleted file mode 100644 index 34acc058aa..0000000000 --- a/packages/host/plugin-inventory/src/invariant.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/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-host-plugin-inventory' - -/** Cordis companion plugin name. */ -export const name = 'host-plugin-inventory-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/host/plugin-inventory/tests/invariant.spec.ts b/packages/host/plugin-inventory/tests/invariant.spec.ts deleted file mode 100644 index 24c99e9c5e..0000000000 --- a/packages/host/plugin-inventory/tests/invariant.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as PluginInventoryInvariant from '../src/invariant.ts' - -describe('plugin-inventory invariant companion', () => { - it('registers the package-owned empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = ctx.plugin(PluginInventoryInvariant) - await expect(fiber.await()).resolves.toBeDefined() - await fiber.dispose() - await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 5bd45b3f3c..6b40877362 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../typert/protocol" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index e5f84d4a7a..b4eb84854c 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/host/webserver/README.md -README.md: 4211c640a0e7386e3380a4289e5ba619818bd094 -README.zh.md: 6ad4f549cd234221cfef88ef0bfd9541fbe50043 +README.md: df1881a3a16b69f61127956127c7806c70793f91 +README.zh.md: ccbf15ff76da653ba04cdc7395aa174343e7753a diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 4211c640a0..df1881a3a1 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -75,6 +75,7 @@ The package is a plain route registry with no harness vocabulary: `WebServer` ex | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `WebServer` service: route tables, fallback seat, index rendering, matching, lifecycle | +| — | No runtime invariant companion is published; route registration and disposal mutate one route table through the same service, so a register/dispose probe only re-executes the implementation. Real routing and HMR tests own the behavior. | | [`src/injections.ts`](src/injections.ts) | Structured `IndexInjection` rows and `renderIndexInjections` row rendering | diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 6ad4f549cd..ccbf15ff76 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -75,6 +75,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `WebServer` 服务:路由表、回退席位、index 渲染、匹配、生命周期 | +| — | 不发布运行时不变式伴生入口;路由注册与释放通过同一服务修改同一张路由表,register/dispose 探针只会重复执行实现。真实路由与 HMR 测试负责该行为。 | | [`src/injections.ts`](src/injections.ts) | 结构化 `IndexInjection` 行与 `renderIndexInjections` 行渲染 | diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 5715d31f09..797d5d5d0c 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -18,22 +18,16 @@ "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/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", @@ -42,7 +36,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@types/compression": "^1.8.1", "@types/negotiator": "^0.6.5" } diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts deleted file mode 100644 index 683982a1c8..0000000000 --- a/packages/host/webserver/src/invariant.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-webserver`. - * @module @deepseek-ai/dsh-host-webserver/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-host-webserver' - -/** Cordis companion plugin name. */ -export const name = 'host-webserver-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * Owned relation: HTTP and upgrade route registrations and their disposers must stay - * symmetric — after the owning fiber of a registered route unloads, the - * route table must no longer answer for its path (a stale route would keep - * serving a disposed plugin's handler). Checked on every fiber teardown - * (cordis 'internal/plugin'): the service's own registry state is compared - * against the set of live fibers' registrations indirectly, by probing that - * dispose really removed the entry — the register() disposer contract. - */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.on('internal/plugin', () => { - const server = ctx.get('webServer') as - | { - register(route: { kind: 'exact'; path: string; handler: () => void }): () => void - registerUpgrade(route: { path: string; handler: () => void }): () => void - } - | undefined - if (server === undefined) return // no webserver row in this composition - // Register/dispose probe on a reserved path: if dispose leaves the route - // behind, a second register throws the duplicate error — the asymmetry. - // Each register(probe)() is one register+dispose cycle, so the probe never - // leaves residue; a leftover from the first cycle makes the second throw. - const probe = { kind: 'exact' as const, path: '/__dsh_invariant_probe__', handler: () => {} } - try { - server.register(probe)() - server.register(probe)() - const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} } - server.registerUpgrade(upgradeProbe)() - server.registerUpgrade(upgradeProbe)() - } catch { - fail('webServer route disposer left a route registered — route tables and fiber lifecycles diverged') - } - }, { 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/host/webserver/tsconfig.json b/packages/host/webserver/tsconfig.json index 62d2a14dc0..339809d695 100644 --- a/packages/host/webserver/tsconfig.json +++ b/packages/host/webserver/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/schemastery" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/identity/anonymous-user-id/README.i18n.yaml b/packages/identity/anonymous-user-id/README.i18n.yaml index 7f772adfca..21693e9d70 100644 --- a/packages/identity/anonymous-user-id/README.i18n.yaml +++ b/packages/identity/anonymous-user-id/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/identity/anonymous-user-id/README.md -README.md: 824daacd0afd42bf79d2434392f8a77781ba375f -README.zh.md: 6cf73ef718808bce1f649f98918dd1be0b0453b5 +README.md: d3865070206d624c21e202161e5f2089a8c1ca3e +README.zh.md: a731e12880d68faa1fff0c85b431e7e94893cfc3 diff --git a/packages/identity/anonymous-user-id/README.md b/packages/identity/anonymous-user-id/README.md index 824daacd0a..d386507020 100644 --- a/packages/identity/anonymous-user-id/README.md +++ b/packages/identity/anonymous-user-id/README.md @@ -66,16 +66,15 @@ This section explains the design decisions behind the package and points at the - **Random, never derived.** The id comes from `crypto.randomUUID()`; it is never derived from the hostname, network address, git remote, or any other identifying source, so anonymity is a property of the mint. - **Synchronous and memoized.** One process touches the disk once: reads and writes are synchronous, and the result is memoized per resolved file path. - **Best-effort persistence.** A write failure still returns a usable id for the run, so telemetry and feedback never block on an unwritable home. -- **Library, not plugin.** There is no Cordis plugin entry or config; the invariant companion installs an empty installer because the package owns no event stream or public mutable relation to compare without creating the id as a side effect. +- **Library, not plugin.** There is no Cordis plugin entry or config. No invariant companion is published because the package owns no event stream or public mutable relation to compare without creating the id as a side effect. ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Library entry: `getOrCreateAnonymousUserId`, file persistence, per-path memoization | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion with an empty installer (no runtime invariant; the only relation is private and side-effecting) | +| — | No runtime invariant companion is published; the API owns one private memo and one best-effort file, with no independent event stream or public mutable relation for a companion to compare without creating the identity as a side effect. | | [`tests/anonymous-user-id.spec.ts`](tests/anonymous-user-id.spec.ts) | Exercised behavior: mint, persistence, corruption, concurrency, memoization | -| [`tests/invariant.spec.ts`](tests/invariant.spec.ts) | Companion registration through the invariants service | ### The API @@ -137,8 +136,8 @@ This Dev Note is working context for maintainers: open questions and directions The persistence contract is a bare UUID line with no version marker. Adding a second value beside the id, or wrapping the line in a container, has no migration story for existing files; a versioned line format is one way to make such a change safe. -#### Open: invariant coverage +#### Open: invariant observation point -The invariant companion registers an empty installer because no relation can be checked without creating the id as a side effect. A future invariant could compare a re-read of the persisted file against the memoized id at a safe observation point. +No invariant companion is published because no relation can be checked without creating the id as a side effect. A future observation point could support comparing a re-read of the persisted file against the memoized id. diff --git a/packages/identity/anonymous-user-id/README.zh.md b/packages/identity/anonymous-user-id/README.zh.md index 6cf73ef718..a731e12880 100644 --- a/packages/identity/anonymous-user-id/README.zh.md +++ b/packages/identity/anonymous-user-id/README.zh.md @@ -66,16 +66,15 @@ const userId = getOrCreateAnonymousUserId() // stable for the process lifetime - **随机生成,绝不派生。** id 来自 `crypto.randomUUID()`;绝不从 hostname、网络地址、git remote 或任何其他可识别来源派生,因此匿名性是生成过程的属性。 - **同步且记忆化。** 一个进程只触碰一次磁盘:读写都是同步的,结果按解析后的文件路径记忆化。 - **Best-effort 持久化。** 写入失败仍会为本次运行返回可用 id,遥测与反馈因此不会因 home 不可写而阻塞。 -- **库而非插件。** 没有 Cordis 插件入口或配置;不变式伴生插件安装空安装器,因为本包不拥有任何事件流或公开可变关系,无法在不产生创建 id 这一副作用的情况下比较。 +- **库而非插件。** 没有 Cordis 插件入口或配置。不发布不变式伴生入口,因为本包不拥有任何事件流或公开可变关系,无法在不产生创建 id 这一副作用的情况下比较。 ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 库入口:`getOrCreateAnonymousUserId`、文件持久化、按路径记忆化 | -| [`src/invariant.ts`](src/invariant.ts) | 带空安装器的不变式伴生插件(无运行时不变式;唯一的关系是私有的且带副作用) | +| — | 不发布运行时不变式伴生入口;唯一的关系是私有的且带副作用。 | | [`tests/anonymous-user-id.spec.ts`](tests/anonymous-user-id.spec.ts) | 已演练行为:生成、持久化、损坏、并发、记忆化 | -| [`tests/invariant.spec.ts`](tests/invariant.spec.ts) | 通过 invariants 服务注册伴生插件 | ### API @@ -137,8 +136,8 @@ const userId = getOrCreateAnonymousUserId() // stable for the process lifetime 持久化约定是没有任何版本标记的裸 UUID 行。在 id 旁边增加第二个值,或用容器包裹该行,对现有文件都没有迁移方案;带版本的行格式是让此类变更安全的一种方式。 -#### 开放:不变式覆盖 +#### 开放:不变式观测点 -不变式伴生插件注册空安装器,因为任何关系都无法在不产生创建 id 这一副作用的情况下检查。未来的不变式可以在安全的观测点上,把重新读取的持久化文件与记忆化的 id 进行比较。 +不发布不变式伴生入口,因为任何关系都无法在不产生创建 id 这一副作用的情况下检查。未来若有安全的观测点,可以把重新读取的持久化文件与记忆化的 id 进行比较。 diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 543541487f..6f3e91760f 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -18,28 +18,21 @@ "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-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/identity/anonymous-user-id/src/invariant.ts b/packages/identity/anonymous-user-id/src/invariant.ts deleted file mode 100644 index b070d4cb34..0000000000 --- a/packages/identity/anonymous-user-id/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-anonymous-user-id`. - * @module @deepseek-ai/dsh-anonymous-user-id/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-anonymous-user-id' - -/** Cordis companion plugin name. */ -export const name = 'anonymous-user-id-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the API owns one private memo and one best-effort - * file, with no independent event stream or public mutable relation for a - * companion to compare without creating the identity as a side effect. - */ -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/identity/anonymous-user-id/tests/invariant.spec.ts b/packages/identity/anonymous-user-id/tests/invariant.spec.ts deleted file mode 100644 index 9de4730527..0000000000 --- a/packages/identity/anonymous-user-id/tests/invariant.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as UserIdInvariant from '@deepseek-ai/dsh-anonymous-user-id/invariant' - -describe('invariant companion', () => { - it('registers the package ownership with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(UserIdInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/identity/anonymous-user-id/tsconfig.json b/packages/identity/anonymous-user-id/tsconfig.json index 8c9ae33db7..5a3c97ad76 100644 --- a/packages/identity/anonymous-user-id/tsconfig.json +++ b/packages/identity/anonymous-user-id/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../util/home-paths" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/interaction/tool-ask-user/README.i18n.yaml b/packages/interaction/tool-ask-user/README.i18n.yaml index 03a1b41c80..a87a755d07 100644 --- a/packages/interaction/tool-ask-user/README.i18n.yaml +++ b/packages/interaction/tool-ask-user/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/tool-ask-user/README.md -README.md: ec123b206bb4f2d91680b09e5f5dd10efc4c3d0d -README.zh.md: e86e8cee1561d52af36dcba1dca3bfe14ec98da4 +README.md: 18240949617daf1ada9d49178fa47cf5a44139ef +README.zh.md: d7c43932aa847eb5bb622741191f90f880215af8 diff --git a/packages/interaction/tool-ask-user/README.md b/packages/interaction/tool-ask-user/README.md index ec123b206b..1824094961 100644 --- a/packages/interaction/tool-ask-user/README.md +++ b/packages/interaction/tool-ask-user/README.md @@ -74,7 +74,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Tool registration: `ask_user_question` schema, execute path, result render | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam owns execution relations) | +| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. | ### Consumer role diff --git a/packages/interaction/tool-ask-user/README.zh.md b/packages/interaction/tool-ask-user/README.zh.md index e86e8cee15..d7c43932aa 100644 --- a/packages/interaction/tool-ask-user/README.zh.md +++ b/packages/interaction/tool-ask-user/README.zh.md @@ -74,7 +74,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 工具注册:`ask_user_question` schema、执行路径、结果渲染 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;执行关系由 seam 拥有) | +| — | 不发布运行时不变式伴生入口;执行关系由 seam 拥有。 | ### Consumer 角色 diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 4bdeb65940..ea6ac30836 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -18,29 +18,22 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/interaction/tool-ask-user/src/invariant.ts b/packages/interaction/tool-ask-user/src/invariant.ts deleted file mode 100644 index d723a4bc31..0000000000 --- a/packages/interaction/tool-ask-user/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-ask-user`. - * @module @deepseek-ai/dsh-tool-ask-user/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-tool-ask-user' - -/** Cordis companion plugin name. */ -export const name = 'tool-ask-user-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/interaction/tool-ask-user/tsconfig.json b/packages/interaction/tool-ask-user/tsconfig.json index 8551ae4349..961fac097f 100644 --- a/packages/interaction/tool-ask-user/tsconfig.json +++ b/packages/interaction/tool-ask-user/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../user-questions" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index c82cbdda18..f40b1b0bfa 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: f5b8c8f6d9d2d376a60d32d096cacf36fede5a7b -README.zh.md: cbabec7f551ab2257e860c45bbd27b99321c6e05 +README.md: 32093da6a319dbf473b28d29ddd60402785b5fce +README.zh.md: f8d1f1b8971d63c2569e59cdc0512e5c950dd2c6 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index f5b8c8f6d9..32093da6a3 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -75,3 +75,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. None. + +**Runtime invariant:** No companion is published. The single provider slot is validated at registration and asks return directly to their caller; the seam publishes no independent request/answer audit stream. diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index cbabec7f55..f8d1f1b897 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -75,3 +75,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。单个 provider slot 在注册时校验,ask 结果直接返回调用方;该 seam 不发布独立 request/answer 审计流。 diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index e2313a3a97..9e9ac8a361 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,21 +27,18 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/interaction/user-questions/src/invariant.ts b/packages/interaction/user-questions/src/invariant.ts deleted file mode 100644 index 872d7592dd..0000000000 --- a/packages/interaction/user-questions/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-user-questions`. - * @module @deepseek-ai/dsh-user-questions/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-user-questions' - -/** Cordis companion plugin name. */ -export const name = 'user-questions-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the single provider slot is validated at registration and asks return - * directly to their caller; the seam publishes no independent request/answer audit stream. - */ -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/interaction/user-questions/tsconfig.json b/packages/interaction/user-questions/tsconfig.json index 601cde72d6..178ff39f3f 100644 --- a/packages/interaction/user-questions/tsconfig.json +++ b/packages/interaction/user-questions/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../llm/llm" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 6e514ac7e3..166154a3dd 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-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/jobs/jobs-local/README.md -README.md: cfbda98ccf59bcd12e931350c708cc15b30ec54c -README.zh.md: 07acfc3c75ef730328792ab1aa45197bfcd5c849 +README.md: 33bf131f3cfe8be2421979e68dda035d289f0bc7 +README.zh.md: 58f93ad2588efd263ff8da08fff13b31b99a18be diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index cfbda98ccf..33bf131f3c 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -80,7 +80,7 @@ This section explains the design decisions behind the registry and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `LocalJobRegistry`, admission, lifecycle, teardown | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; snapshot checks live in `dsh-jobs/invariant`) | +| — | No runtime invariant companion is published; `@deepseek-ai/dsh-jobs/invariant` owns per-snapshot identity, status, timestamp, and owner checks. This provider's admission decision uses private configuration and must fail before a backend starter runs; `LocalJobRegistry.start()` enforces it synchronously for current producers. Repeating an aggregate after publication would expose private configuration solely to this companion and would not verify the fail-closed pre-start guarantee. | ### Scope layers diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 07acfc3c75..58f93ad258 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -80,7 +80,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`LocalJobRegistry`、准入、生命周期、销毁 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;快照检查位于 `dsh-jobs/invariant`) | +| — | 不发布运行时不变式伴生入口;快照检查位于 `dsh-jobs/invariant`。 | ### scope 分层 diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 02b00e36e1..19ba3a7c60 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", @@ -47,7 +41,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/jobs/jobs-local/src/invariant.ts b/packages/jobs/jobs-local/src/invariant.ts deleted file mode 100644 index 4fc661c43e..0000000000 --- a/packages/jobs/jobs-local/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-jobs-local`. - * @module @deepseek-ai/dsh-jobs-local/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-jobs-local' - -/** Cordis companion plugin name. */ -export const name = 'jobs-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: `@deepseek-ai/dsh-jobs/invariant` owns per-snapshot identity, status, - * timestamp, and owner checks. This provider's admission decision uses private configuration and - * must fail before a backend starter runs; `LocalJobRegistry.start()` enforces it synchronously - * for current producers. Repeating an aggregate after publication would expose private - * configuration solely to this companion and would not verify the fail-closed pre-start guarantee. - */ -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/jobs/jobs-local/tsconfig.json b/packages/jobs/jobs-local/tsconfig.json index b32c8476f2..f94627dd8f 100644 --- a/packages/jobs/jobs-local/tsconfig.json +++ b/packages/jobs/jobs-local/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../jobs" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index c17b3e0f51..ca617bc8fb 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-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/jobs/tool-jobs/README.md -README.md: f88b7a6643d0fb7c4f3a5196fa286bf205a29255 -README.zh.md: 0140e5d2cdda21d8c18ca516c159c36036a53281 +README.md: bb9fea89b4aabe38296c01c910f36e99244680db +README.zh.md: 9820b1536f7f5c8860e7f67b1eee956e6d240462 diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md index f88b7a6643..bb9fea89b4 100644 --- a/packages/jobs/tool-jobs/README.md +++ b/packages/jobs/tool-jobs/README.md @@ -83,7 +83,7 @@ This section explains the design decisions behind the tools and points at the co | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, completion listener, prompt section, output capping | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; execution relations are owned by the capability seam) | +| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. | ### Output capping diff --git a/packages/jobs/tool-jobs/README.zh.md b/packages/jobs/tool-jobs/README.zh.md index 0140e5d2cd..9820b1536f 100644 --- a/packages/jobs/tool-jobs/README.zh.md +++ b/packages/jobs/tool-jobs/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、完成监听器、提示词区段、输出上限 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;执行关系归能力 seam 所有) | +| — | 不发布运行时不变式伴生入口;执行关系归能力 seam 所有。 | ### 输出上限 diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 07cee3a02b..43766ca175 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -18,16 +18,11 @@ "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", @@ -36,7 +31,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -46,7 +40,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/jobs/tool-jobs/src/invariant.ts b/packages/jobs/tool-jobs/src/invariant.ts deleted file mode 100644 index 9e1d0d93ba..0000000000 --- a/packages/jobs/tool-jobs/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-jobs`. - * @module @deepseek-ai/dsh-tool-jobs/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-tool-jobs' - -/** Cordis companion plugin name. */ -export const name = 'tool-jobs-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/jobs/tool-jobs/tsconfig.json b/packages/jobs/tool-jobs/tsconfig.json index ca8dd8145a..89d9c856d2 100644 --- a/packages/jobs/tool-jobs/tsconfig.json +++ b/packages/jobs/tool-jobs/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../jobs" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml index 6878447100..2ebcd4a047 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml +++ b/packages/llm/deepseek-llm-api-extensions/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/deepseek-llm-api-extensions/README.md -README.md: cc5d2687ada61843e000b6ab180ab9c19eeee395 -README.zh.md: 03b7969669d4acd2c6cc290f0d34883199040f2c +README.md: cf35ee359d408661a9841de80fcc8c6b73586c80 +README.zh.md: 7ce7e9d997ef63cdf8ac20bcccd4f36370ba823f diff --git a/packages/llm/deepseek-llm-api-extensions/README.md b/packages/llm/deepseek-llm-api-extensions/README.md index cc5d2687ad..cf35ee359d 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.md +++ b/packages/llm/deepseek-llm-api-extensions/README.md @@ -57,3 +57,5 @@ None; registry fields are model-hidden provider metadata and do not alter the se None. + +**Runtime invariant:** No companion is published. Duplicate ownership, detached output, and one acceptance settlement are enforced inside the registry operation that owns each decision. diff --git a/packages/llm/deepseek-llm-api-extensions/README.zh.md b/packages/llm/deepseek-llm-api-extensions/README.zh.md index 03b7969669..7ce7e9d997 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.zh.md +++ b/packages/llm/deepseek-llm-api-extensions/README.zh.md @@ -57,3 +57,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。重复所有权、detached output 与单次 acceptance settlement 都在拥有该决策的 registry 操作中强制。 diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index a7a5c17eda..13efbc0bbd 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,17 +27,14 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/deepseek-llm-api-extensions/src/invariant.ts b/packages/llm/deepseek-llm-api-extensions/src/invariant.ts deleted file mode 100644 index ed743eed65..0000000000 --- a/packages/llm/deepseek-llm-api-extensions/src/invariant.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-deepseek-llm-api-extensions`. */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-deepseek-llm-api-extensions' - -/** Cordis companion plugin name. */ -export const name = 'deepseek-llm-api-extensions-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: duplicate ownership, detached output, and one acceptance - * settlement are enforced inside the registry operation that owns each decision. - */ -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/llm/deepseek-llm-api-extensions/tsconfig.json b/packages/llm/deepseek-llm-api-extensions/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/llm/deepseek-llm-api-extensions/tsconfig.json +++ b/packages/llm/deepseek-llm-api-extensions/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 5ef3f821f9..85043aa674 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: d97ef383de0921c45b60da2b32c9c1b5cdd20bbf -README.zh.md: c666a40b5bf2718742451ea17c0479169617e2b4 +README.md: 58639be34cb116f3103f579b277504818cb82278 +README.zh.md: d5a5be0847bc8f3ad069e7bb1fff3a42e1662e5d diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index d97ef383de..58639be34c 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -201,3 +201,5 @@ This Dev Note is non-authoritative working context: undecided directions and not - The `off` reasoning effort never crosses the wire as `reasoning_effort: 'off'`; it serializes as `thinking: { type: 'disabled' }` and omits the field, which keeps the wire spelling valid for gateways that reject unknown effort values. + +**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c666a40b5b..d5a5be0847 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -201,3 +201,5 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - `off` 推理强度绝不会以 `reasoning_effort: 'off'` 过线;它序列化为 `thinking: { type: 'disabled' }` 并省略该字段,从而对拒绝未知强度取值的网关保持协议拼写有效。 + +**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index ebddd5b4f5..75336d2141 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -18,16 +18,11 @@ "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", @@ -39,7 +34,6 @@ "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "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:^", @@ -61,7 +55,6 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "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:^", diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts deleted file mode 100644 index 921e02a252..0000000000 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-llm-deepseek`. - * @module @deepseek-ai/dsh-llm-deepseek/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-llm-deepseek' - -/** Cordis companion plugin name. */ -export const name = 'llm-deepseek-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 81b7bb1eca..d0a247a8af 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -47,9 +47,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/timeout" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 38b01bcd52..963847910e 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: dcafde78e176bd0acd142145d651d6d5b5539626 -README.zh.md: a7292638b8449d7192d3e003f0377967d11ef94d +README.md: fd8a244ecb5355e8d5d9a4146eb6de19bfcd1e9b +README.zh.md: 0fff4ee39bc8bf8a85ff5742ad438f8a8ec80fde diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index dcafde78e1..fd8a244ecb 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -232,3 +232,5 @@ This Dev Note is non-authoritative working context: undecided directions and not - The `compat` switch set is pinned to pi-ai's compat types by drift gates; an upstream upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it. + +**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a7292638b8..0fff4ee39b 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -232,3 +232,5 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - `compat` 开关集合由漂移门禁钉在 pi-ai 的 compat 类型上;上游升级若新增字段、为更多协议赋予 compat 类型或扩大值联合,会在有人分类前让构建失败。 + +**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c8c3963f28..c25126e956 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -18,16 +18,11 @@ "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", @@ -37,7 +32,6 @@ "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", @@ -53,7 +47,6 @@ "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts deleted file mode 100644 index 6fdf5b3126..0000000000 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-llm-pi-ai`. - * @module @deepseek-ai/dsh-llm-pi-ai/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-llm-pi-ai' - -/** Cordis companion plugin name. */ -export const name = 'llm-pi-ai-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 8200210b6d..21504002a4 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -38,9 +38,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/timeout" } diff --git a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml b/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml index 987fec52e5..85097d8624 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml +++ b/packages/llm/plugin-package-inventory-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/plugin-package-inventory-deepseek/README.md -README.md: 36fe380b8805a1b7af5d455e6309e62b2512eb0d -README.zh.md: 2d7bd12765499f4c00dcb5c4b8770ce5ff90f92d +README.md: 8da1b526608348c8c6ec0ff75d7d472921e12aed +README.zh.md: 72fa53e0fbea03ae9e0b218b52d162905834f4f7 diff --git a/packages/llm/plugin-package-inventory-deepseek/README.md b/packages/llm/plugin-package-inventory-deepseek/README.md index 36fe380b88..8da1b52660 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.md +++ b/packages/llm/plugin-package-inventory-deepseek/README.md @@ -74,3 +74,5 @@ None; package lifecycle changes do not alter the model-visible prefix. None. + +**Runtime invariant:** No companion is published. Each request reads authoritative Loader fiber state and package manifests directly; the plugin retains no independently mutable inventory. diff --git a/packages/llm/plugin-package-inventory-deepseek/README.zh.md b/packages/llm/plugin-package-inventory-deepseek/README.zh.md index 2d7bd12765..72fa53e0fb 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.zh.md +++ b/packages/llm/plugin-package-inventory-deepseek/README.zh.md @@ -74,3 +74,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。每次请求直接读取权威 Loader fiber 状态与 package manifest,插件不保留独立可变 inventory。 diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 42fb4fd061..b9c68d74d6 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,7 +27,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], @@ -44,7 +39,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -59,7 +53,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/llm/plugin-package-inventory-deepseek/src/invariant.ts b/packages/llm/plugin-package-inventory-deepseek/src/invariant.ts deleted file mode 100644 index 0325dc3c4f..0000000000 --- a/packages/llm/plugin-package-inventory-deepseek/src/invariant.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-plugin-package-inventory-deepseek`. */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-plugin-package-inventory-deepseek' - -/** Cordis companion plugin name. */ -export const name = 'plugin-package-inventory-deepseek-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: each request reads authoritative Loader fiber state and - * package manifests directly; the plugin retains no independently mutable inventory. - */ -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/llm/plugin-package-inventory-deepseek/tsconfig.json b/packages/llm/plugin-package-inventory-deepseek/tsconfig.json index 4edcfd192d..939e130d32 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tsconfig.json +++ b/packages/llm/plugin-package-inventory-deepseek/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../deepseek-llm-api-extensions" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 9a01b053eb..bb885f0bfc 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/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/token-meter/README.md -README.md: 4df0c4d4f9f64f70299efb54bf21595c71b4eb0c -README.zh.md: 3937e68d1739b752c589de03e04eb6597232505b +README.md: f2a4f7f7c70de4a08fd824e6e8001622ddb456b5 +README.zh.md: 3b163198279d4b254cd78638ebcad479d063630f diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 4df0c4d4f9..f2a4f7f7c7 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -148,3 +148,5 @@ This Dev Note is non-authoritative working context: notes for maintainers and op - A per-provider exact tokenizer is not decided; keeping one deterministic heuristic is what makes every consumer's measurement agree and replay-stable. + +**Runtime invariant:** No companion is published. Token estimates are per-call outputs and the private session cache is invalidated at its event mutation boundary. The package's three projections do expose observation streams, but their schemas fix the JSON payloads; the usage folds replace same-attempt samples, so totals need not be monotone when a final sample corrects an earlier chunk, and the composition fold prices through the same `estimate.ts` heuristic as the measurement service and subtracts producer-logged shadow prices derived from that service's own fixed-heuristic node prices, which makes its message figure equal the sum of `measure().nodes[].heuristicTokens` by construction rather than by a relation worth observing at runtime; the route-priced `surfaceTokens` deliberately diverges by the routed model's image repricing. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 3937e68d17..3b16319827 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -148,3 +148,5 @@ const price = ctx.tokenMeter.estimateMessage(message) - 按提供方的精确分词器尚未决定;保持单一确定性启发式规则,正是让每个消费方的测量一致且回放稳定的原因。 + +**运行时不变式:** 不发布伴生入口。token estimate 是按调用输出,私有 Session cache 在事件变更处失效;其 projection 与计价均来自同一 schema 和 heuristic,没有可独立交叉核对的运行时关系。 diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index fcae3d1ccd..85c7367902 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -18,10 +18,6 @@ "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.d.ts", "default": "./lib/types/client.js" @@ -31,14 +27,12 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -51,7 +45,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts deleted file mode 100644 index 3c601db4ce..0000000000 --- a/packages/llm/token-meter/src/invariant.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-token-meter`. - * @module @deepseek-ai/dsh-token-meter/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-token-meter' - -/** Cordis companion plugin name. */ -export const name = 'token-meter-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: token estimates are per-call outputs and the private - * session cache is invalidated at its event mutation boundary. The package's - * three projections do expose observation streams, but their schemas fix the - * JSON payloads; the usage folds replace same-attempt samples, so totals need - * not be monotone when a final sample corrects an earlier chunk, and the - * composition fold prices through the same `estimate.ts` heuristic as the - * measurement service and subtracts producer-logged shadow prices derived - * from that service's own fixed-heuristic node prices, which makes its - * message figure equal the sum of `measure().nodes[].heuristicTokens` by - * construction rather than by a relation worth observing at runtime; the - * route-priced `surfaceTokens` deliberately diverges by the routed model's - * image repricing. - */ -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/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index c9eb57b72d..1d8de4abf6 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../session/session-projection" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 52663fcb35..d8454b9a48 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/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/lsp/lsp-stdio/README.md -README.md: 7acb5a591f3cf50a02d01e96f9b4a26ff3bf693b -README.zh.md: 27020c1babbcff8acde890db5df11eaede45301a +README.md: 8cb7fbcc761b72dac7c291040c390de7ebf49b99 +README.zh.md: 4186e8c1b66b0f2e66ace8f82c76fce58cf00a49 diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 7acb5a591f..8cb7fbcc76 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -106,7 +106,7 @@ This section explains the design decisions behind the provider and where the cod | [`src/protocol.ts`](src/protocol.ts) | Wire-type subset: capabilities, locations, hover, text-document synchronization | | [`src/translate.ts`](src/translate.ts) | Capability checks, UTF-16 negotiation, `Location`/`LocationLink`/hover normalization | | [`src/abort.ts`](src/abort.ts) | Cancellation helpers fusing caller and disposal signals | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; pools and queues are private state) | +| — | No runtime invariant companion is published; process pools and per-workspace queues are private implementation state, and this provider publishes no independent lifecycle event stream or enumerable snapshot. | ### Protocol behavior diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index 27020c1bab..4186e8c1b6 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -106,7 +106,7 @@ kind: "package-reference" | [`src/protocol.ts`](src/protocol.ts) | 协议类型子集:能力、位置、悬停、文本文档同步 | | [`src/translate.ts`](src/translate.ts) | 能力检查、UTF-16 协商、`Location`/`LocationLink`/hover 规范化 | | [`src/abort.ts`](src/abort.ts) | 融合调用方与释放信号的取消辅助 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;进程池与队列是私有状态) | +| — | 不发布运行时不变式伴生入口;进程池与队列是私有状态。 | ### 协议行为 diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 641f1b5002..52aa552b34 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -18,23 +18,17 @@ "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-brand": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -48,7 +42,6 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", diff --git a/packages/lsp/lsp-stdio/src/invariant.ts b/packages/lsp/lsp-stdio/src/invariant.ts deleted file mode 100644 index 9d37464f83..0000000000 --- a/packages/lsp/lsp-stdio/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-lsp-stdio`. - * @module @deepseek-ai/dsh-lsp-stdio/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-lsp-stdio' - -/** Cordis companion plugin name. */ -export const name = 'lsp-stdio-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: process pools and per-workspace queues are private implementation state, - * and this provider publishes no independent lifecycle event stream or enumerable snapshot. - */ -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/lsp/lsp-stdio/tsconfig.json b/packages/lsp/lsp-stdio/tsconfig.json index cfa4a13f29..d133816fda 100644 --- a/packages/lsp/lsp-stdio/tsconfig.json +++ b/packages/lsp/lsp-stdio/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/lsp/lsp/README.i18n.yaml b/packages/lsp/lsp/README.i18n.yaml index 299737fd3f..f031981aa9 100644 --- a/packages/lsp/lsp/README.i18n.yaml +++ b/packages/lsp/lsp/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/lsp/lsp/README.md -README.md: 807f9301784423553c11b6b544f9e1eaee60e78b -README.zh.md: 8674132e2de58c7327db7431d658872a17589309 +README.md: 119ddf8775e37fa32e4db123ea2418d0d8c8bbb2 +README.zh.md: 138dc9599c3e41710f5fcfc4cc62452cab378bbe diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 807f930178..119ddf8775 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -87,7 +87,7 @@ This section explains the design decisions behind the seam and where the code re | [`src/index.ts`](src/index.ts) | Plugin entry: `Lsp` service, `registerProvider`/`query`, `finalExtension`, `LspError` codes | | [`src/types.ts`](src/types.ts) | Seam vocabulary: request, result, provider, and service contracts | | [`src/brand.ts`](src/brand.ts) | `LspProviderId` branded-id type and factory | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; routes are private atomic state) | +| — | No runtime invariant companion is published; provider ids and extension routes are private, atomically updated state; the seam exposes neither an enumerable snapshot nor lifecycle events to compare independently. | ### Registration and selection lifecycle diff --git a/packages/lsp/lsp/README.zh.md b/packages/lsp/lsp/README.zh.md index 8674132e2d..138dc9599c 100644 --- a/packages/lsp/lsp/README.zh.md +++ b/packages/lsp/lsp/README.zh.md @@ -87,7 +87,7 @@ seam 需要提供方与消费方才能发挥作用。最小组合挂载服务、 | [`src/index.ts`](src/index.ts) | 插件入口:`Lsp` 服务、`registerProvider`/`query`、`finalExtension`、`LspError` code | | [`src/types.ts`](src/types.ts) | seam 词汇:请求、结果、提供方与服务约定 | | [`src/brand.ts`](src/brand.ts) | `LspProviderId` 品牌化 id 类型与工厂 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;路由是私有原子状态) | +| — | 不发布运行时不变式伴生入口;路由是私有原子状态。 | ### 注册与选择生命周期 diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 2b09bbe2be..1cb78551c2 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -18,28 +18,21 @@ "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-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/lsp/lsp/src/invariant.ts b/packages/lsp/lsp/src/invariant.ts deleted file mode 100644 index 512775798b..0000000000 --- a/packages/lsp/lsp/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-lsp`. - * @module @deepseek-ai/dsh-lsp/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-lsp' - -/** Cordis companion plugin name. */ -export const name = 'lsp-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: provider ids and extension routes are private, atomically updated state; - * the seam exposes neither an enumerable snapshot nor lifecycle events to compare independently. - */ -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/lsp/lsp/tsconfig.json b/packages/lsp/lsp/tsconfig.json index 25ee158bdf..7ca1556695 100644 --- a/packages/lsp/lsp/tsconfig.json +++ b/packages/lsp/lsp/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../llm/llm" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml index b496179768..641de89fb3 100644 --- a/packages/lsp/tool-lsp/README.i18n.yaml +++ b/packages/lsp/tool-lsp/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/lsp/tool-lsp/README.md -README.md: 528ae82aa5aad8286b8571a7b70e017b971717d6 -README.zh.md: 0eaa50129708e39ec9011052f249e9f7badf4df4 +README.md: 437cd4a35ca58a43bcc0af7c0f81d7fcddb4812f +README.zh.md: f67f1cdff8a55857dc63e994958191953d7d0158 diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index 528ae82aa5..437cd4a35c 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -75,7 +75,7 @@ This section explains the design decisions behind the tool and where the code re | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, tool registration, system-prompt section, execution | | [`src/render.ts`](src/render.ts) | Pure formatting, coordinate conversion, URI resolution, result caps, UI presentation | | [`src/session-cwd.ts`](src/session-cwd.ts) | Workspace root from the session `header.cwd` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; stateless adapter) | +| — | No runtime invariant companion is published; this stateless adapter contributes one tool and prompt section, while query lifecycle and result relations remain owned by the tool and LSP seams it composes. | diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md index 0eaa501297..f67f1cdff8 100644 --- a/packages/lsp/tool-lsp/README.zh.md +++ b/packages/lsp/tool-lsp/README.zh.md @@ -75,7 +75,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:config schema、工具注册、系统提示词区段、执行 | | [`src/render.ts`](src/render.ts) | 纯格式化、坐标转换、URI 解析、结果上限、UI 呈现 | | [`src/session-cwd.ts`](src/session-cwd.ts) | 从会话 `header.cwd` 取得工作区根目录 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;无状态适配器) | +| — | 不发布运行时不变式伴生入口;无状态适配器。 | diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 5aab5810e9..63d7b49cb9 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -46,7 +40,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", "@deepseek-ai/dsh-lsp-stdio": "workspace:^", diff --git a/packages/lsp/tool-lsp/src/invariant.ts b/packages/lsp/tool-lsp/src/invariant.ts deleted file mode 100644 index fc9a292fc8..0000000000 --- a/packages/lsp/tool-lsp/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-lsp`. - * @module @deepseek-ai/dsh-tool-lsp/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-tool-lsp' - -/** Cordis companion plugin name. */ -export const name = 'tool-lsp-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this stateless adapter contributes one tool and prompt section, while query - * lifecycle and result relations remain owned by the tool and LSP seams it composes. - */ -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/lsp/tool-lsp/tsconfig.json b/packages/lsp/tool-lsp/tsconfig.json index 681e394bef..e53735f570 100644 --- a/packages/lsp/tool-lsp/tsconfig.json +++ b/packages/lsp/tool-lsp/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../lsp" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 5466123182..44f33721eb 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-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/mcp/mcp-client/README.md -README.md: 929577e886b9f4a438739191191a1f0ee86c6343 -README.zh.md: ca07fd2b5d3547b09524a4097ce10bfb20251563 +README.md: f4979b6a2c9a7cfdbbb11cf7526688ce2549f184 +README.zh.md: e18e05a2a74944ace14f4cca7807dd3456e32dd1 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 929577e886..f4979b6a2c 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -116,7 +116,7 @@ This section explains the design decisions behind the bridge and points at the c | [`src/connection.ts`](src/connection.ts) | Connection supervisor: client generations, reconnect policy, attempt budget, disposal | | [`src/tools.ts`](src/tools.ts) | Tool bridge: discovery, naming, registration swap, execution, image projection | | [`src/transport.ts`](src/transport.ts) | Transport factory: stdio spawn with scrubbed env, Streamable HTTP | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; generations are observable only through the tool registry) | +| — | No runtime invariant companion is published; MCP generations contribute through the tool registry, but the bridge exposes no independent server-to-tool snapshot after an asynchronous resync. | ### Lifecycle and sync diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index ca07fd2b5d..e18e05a2a7 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -116,7 +116,7 @@ kind: "package-reference" | [`src/connection.ts`](src/connection.ts) | 连接监督器:客户端世代、重连策略、尝试预算、dispose | | [`src/tools.ts`](src/tools.ts) | 工具桥接:发现、命名、注册交换、执行、图片投影 | | [`src/transport.ts`](src/transport.ts) | 传输工厂:带清洗环境的 stdio spawn、Streamable HTTP | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;世代只能通过工具注册表观察) | +| — | 不发布运行时不变式伴生入口;世代只能通过工具注册表观察。 | ### 生命周期与同步 diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index fed592a473..09e24ab4c1 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -18,22 +18,16 @@ "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-attachment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -49,7 +43,6 @@ "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts deleted file mode 100644 index 6532d8c2ef..0000000000 --- a/packages/mcp/mcp-client/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-mcp-client`. - * @module @deepseek-ai/dsh-mcp-client/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-mcp-client' - -/** Cordis companion plugin name. */ -export const name = 'mcp-client-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: MCP generations contribute through the tool registry, but the bridge - * exposes no independent server-to-tool snapshot after an asynchronous resync. - */ -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/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index b48aea0922..8c466a1656 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -27,9 +27,6 @@ { "path": "../../subprocess/subprocess" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/timeout" } diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index 4b1d564a42..1146c110f2 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/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/preset/persona/README.md -README.md: c3e6eb33786792f837b4ae07820d87c5327204d6 -README.zh.md: c4825186487e332c2860a6adcf24466cce1e6b17 +README.md: 86f285887f80198b006f0f1a7301e3a794a801fc +README.zh.md: 38fee0fd5abf85878c8f3cdeccba3831657479e7 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md index c3e6eb3378..86f285887f 100644 --- a/packages/preset/persona/README.md +++ b/packages/preset/persona/README.md @@ -72,7 +72,7 @@ Use this row when a preset must change an agent's identity and not only its tool | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, persona section registration, runtime-context suppression | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the prompt registry owns identity, complete-prompt enforcement, and disposal) | +| — | No runtime invariant companion is published; this row owns no event stream or mutable runtime data — it registers one prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing, and disposal. | diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index c482518648..38fee0fd5a 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、人设段落注册、runtime-context 抑制 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;身份、完整提示词执行与资源释放归提示词注册表) | +| — | 不发布运行时不变式伴生入口;身份、完整提示词执行与资源释放归提示词注册表。 | diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 09370f1cfa..40b573711c 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -18,21 +18,15 @@ "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/dsh-system-prompt": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -40,7 +34,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts deleted file mode 100644 index ee63503de3..0000000000 --- a/packages/preset/persona/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-persona`. - * @module @deepseek-ai/dsh-persona/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-persona' - -/** Cordis companion plugin name. */ -export const name = 'persona-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one - * prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing, - * and disposal. - */ -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/preset/persona/tsconfig.json b/packages/preset/persona/tsconfig.json index 89e4ce92df..2c51caef3a 100644 --- a/packages/preset/persona/tsconfig.json +++ b/packages/preset/persona/tsconfig.json @@ -17,9 +17,6 @@ }, { "path": "../../core/system-prompt" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/runtime-diagnostics/invariants/README.i18n.yaml b/packages/runtime-diagnostics/invariants/README.i18n.yaml index c4c6a4bf0a..733dccaa22 100644 --- a/packages/runtime-diagnostics/invariants/README.i18n.yaml +++ b/packages/runtime-diagnostics/invariants/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/runtime-diagnostics/invariants/README.md -README.md: 72bb74cf5b9b367d0a29cb942370c4fa3d86e84c -README.zh.md: b7cffce5cf988bdfedce6b7b5de41d224182e086 +README.md: e5a1faae5a9e906d00215574550a608c1370e357 +README.zh.md: f61c5b6dc7e6ab3f92514b2bc2488d2daaac678e diff --git a/packages/runtime-diagnostics/invariants/README.md b/packages/runtime-diagnostics/invariants/README.md index 72bb74cf5b..e5a1faae5a 100644 --- a/packages/runtime-diagnostics/invariants/README.md +++ b/packages/runtime-diagnostics/invariants/README.md @@ -65,10 +65,10 @@ Each companion protects relationships its package owns, and a companion installs | `dsh-permission-presets`, `dsh-user-approval`, `dsh-commands` | Preset references to live presets, approval asked/decided pairing, command run/done pairing | | `dsh-jobs`, `dsh-tool-todo`, `dsh-time-context` | Job snapshot field relationships, whole-list todo shape, durable clock readings | | `dsh-credentials`, `dsh-settings`, `dsh-storage-domain`, `dsh-workspace` | Commit events against the live service or memory state, entity-cache mirroring | -| `dsh-agent-presets`, `dsh-session-title`, `dsh-plan-mode`, `dsh-schedule`, `dsh-webserver` | Preset mount placement, title source citation, plan-mode payload, schedule stream, route disposer symmetry | +| `dsh-agent-presets`, `dsh-session-title`, `dsh-plan-mode`, `dsh-schedule` | Preset mount placement, title source citation, plan-mode payload, schedule stream | | `dsh-client-hmr`, `dsh-client-modules`, `dsh-client-runtime` | Browser/node-half stat-watcher lifecycle, boot entry graph, slot mutation versioning | -Every other workspace package publishes an empty companion with a `No runtime invariant:` explanation of why nothing is checkable. +Every other workspace package omits the companion and states the package-specific reason in its README. ### Adding a companion to a custom composition @@ -104,14 +104,14 @@ This section explains the design behind the registry; the observable behavior is - **Product-independent registry.** The service imports no session, agent, scope, or agent-loop package and contains none of their checks; companions carry checks next to their owners. - **Real relationships, not synthetic assertions.** A companion checks an event-stream or mutable-data relationship its package owns; confirming a method, plugin name, injection, or fixed pure result is a type, load, or unit-test concern, never a runtime invariant. - **Registration reserves ownership.** A package name is reserved even when filters keep its installer inactive, so two plugins can never silently claim the same name. -- **Exhaustive wiring, mechanically enforced.** `pnpm run verify-package-invariants` rejects generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, wrong registration names, and incomplete export, publication, dependency, or bundle wiring ([contracts note](../../../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). +- **Companion wiring is mechanically enforced.** `pnpm run verify-package-invariants` rejects empty installers, installers that omit or ignore the reporter, wrong registration names, incomplete publication wiring, and stale wiring for omitted companions ([companion-omission note](../../../.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md)). ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `InvariantRegistry` service, selection, registration, `InvariantError` | -| [`src/invariant.ts`](src/invariant.ts) | This package's own companion: an empty installer explaining that registration ownership is the service's own mutation boundary | +| — | No runtime invariant companion is published; registration ownership and child lifecycle are the service's mutation boundary itself; observing them from the same registry would only duplicate its implementation. | ### Selection and registration lifecycle diff --git a/packages/runtime-diagnostics/invariants/README.zh.md b/packages/runtime-diagnostics/invariants/README.zh.md index b7cffce5cf..f61c5b6dc7 100644 --- a/packages/runtime-diagnostics/invariants/README.zh.md +++ b/packages/runtime-diagnostics/invariants/README.zh.md @@ -65,10 +65,10 @@ kind: "package-reference" | `dsh-permission-presets`、`dsh-user-approval`、`dsh-commands` | preset 引用指向活动 preset、审批询问/决定配对、命令运行/完成配对 | | `dsh-jobs`、`dsh-tool-todo`、`dsh-time-context` | 任务快照字段关系、整表 todo 形状、持久时钟读数 | | `dsh-credentials`、`dsh-settings`、`dsh-storage-domain`、`dsh-workspace` | 提交事件对照活动服务或内存状态、实体缓存镜像 | -| `dsh-agent-presets`、`dsh-session-title`、`dsh-plan-mode`、`dsh-schedule`、`dsh-webserver` | preset 挂载位置、标题来源引用、plan-mode 载荷、schedule 流、路由 disposer 对称性 | +| `dsh-agent-presets`、`dsh-session-title`、`dsh-plan-mode`、`dsh-schedule` | preset 挂载位置、标题来源引用、plan-mode 载荷、schedule 流 | | `dsh-client-hmr`、`dsh-client-modules`、`dsh-client-runtime` | 浏览器/node 侧 stat-watcher 生命周期、启动入口图、slot 变更版本化 | -其余每个工作区包都发布一个空配套入口,并以 `No runtime invariant:` 说明为何没有可检查的内容。 +其余工作区包省略伴生入口,并在各自 README 中说明包级原因。 ### 向自定义组合添加配套入口 @@ -104,14 +104,14 @@ ctx.plugin(SessionInvariant) - **与产品无关的注册表。** 服务不导入任何 session、agent、scope 或 agent-loop 包,也不包含它们的检查;配套入口把检查放在其归属者旁边。 - **真实关系,而非人为断言。** 配套入口只检查其包拥有的事件流或可变数据关系;确认方法、插件名、注入或固定纯函数结果是类型、加载或单元测试关注点,绝不是运行时不变量。 - **注册保留归属。** 即使过滤器让 installer 保持非活动,包名也会被保留,因此两个插件永远不会静默认领同一个名字。 -- **穷尽接线,机械强制。** `pnpm run verify-package-invariants` 拒绝生成标记、未说明的空 installer、省略或忽略 reporter 的非空 installer、错误注册名,以及不完整的导出、发布、依赖或 bundle 接线([约定笔记](../../../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md))。 +- **伴生入口接线由机械规则强制。** `pnpm run verify-package-invariants` 拒绝空 installer、省略或忽略 reporter 的 installer、错误注册名、不完整的发布接线,以及省略伴生入口后残留的接线([省略伴生入口笔记](../../../.agents/notes/implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md))。 ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`InvariantRegistry` 服务、选择、注册、`InvariantError` | -| [`src/invariant.ts`](src/invariant.ts) | 本包自己的配套入口:一个空 installer,说明注册归属本身就是服务的变更边界 | +| — | 不发布运行时不变式伴生入口;注册归属本身就是服务的变更边界。 | ### 选择与注册生命周期 diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 50e357335e..a651805f62 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -18,16 +18,11 @@ "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", diff --git a/packages/runtime-diagnostics/invariants/src/invariant.ts b/packages/runtime-diagnostics/invariants/src/invariant.ts deleted file mode 100644 index 81ecc5b2af..0000000000 --- a/packages/runtime-diagnostics/invariants/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-invariants`. - * @module @deepseek-ai/dsh-invariants/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from './index.ts' - -const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' - -/** Cordis companion plugin name. */ -export const name = 'invariants-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: registration ownership and child lifecycle are the service's mutation - * boundary itself; observing them from the same registry would only duplicate its implementation. - */ -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/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index bf87b709ba..cb75e18149 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-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/sandbox/sandbox-local/README.md -README.md: 563ceb649d17045936f92709e76ae32aa7e75407 -README.zh.md: fc72d8766da04dee379c17e0a43fe7aac36bc6ee +README.md: 3cc3c27be2ff04f2ac4c0c541f675acf49c67aee +README.zh.md: 57c8c3d6e67f3b88ddb507a8d8b574c1291c3d7c diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 563ceb649d..3cc3c27be2 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -90,7 +90,7 @@ Each runner's kernel speaks its own denial dialect, carried on every wrap as `de |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: runner chain selection, functional probes, per-call wrap, ACL grant lifecycle | | [`src/profiles.ts`](src/profiles.ts) | Per-platform profile builders: bwrap mounts, Landlock grants, Seatbelt SBPL | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; fail-closed contracts are enforced at the wrap boundary) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index fc72d8766d..57c8c3d6e6 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -90,7 +90,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同 |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:runner 链选择、功能探测、逐调用包装、ACL 授权生命周期 | | [`src/profiles.ts`](src/profiles.ts) | 各平台 profile 构建器:bwrap 挂载、Landlock 授权、Seatbelt SBPL | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;故障关闭约定在包装边界强制执行) | +| — | 不发布运行时不变式伴生入口;故障关闭约定在包装边界强制执行。 | diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index cea6df459d..0953c636a6 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -44,7 +38,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts deleted file mode 100644 index e4f0891631..0000000000 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-local`. - * @module @deepseek-ai/dsh-sandbox-local/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-sandbox-local' - -/** Cordis companion plugin name. */ -export const name = 'sandbox-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 072d6a3025..67e8ee678a 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ecc0ada2ca..35bfcdac02 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: d742789e3f90711ff0cb322a71e73c5caf291a39 -README.zh.md: a5c3a3e92788f96b969d42b127cd7010a0d52d2f +README.md: bba311fbb5337d6390b49a4b2898751906beb0f8 +README.zh.md: 24110bd35cc022e03bfe0156178072cc435dd564 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index d742789e3f..bba311fbb5 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -187,3 +187,5 @@ This Dev Note is working context for maintainers: undecided directions and open The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing workspace ACEs from renamed workspaces is undecided. Both are open directions, not shipped behavior. + +**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond the fail-closed contracts it enforces at each Win32 call boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index a5c3a3e927..24110bd35c 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -187,3 +187,5 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 对异常宽的目录与 FAT 类卷的仅警告立场已记录在上方限制中但尚未实现,回收改名工作区常驻 ACE 的清理命令也尚未决定。两者都是开放方向,不是已交付行为。 + +**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系;fail-closed 约定在每个 Win32 调用处强制。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 7af347e7f1..aa3041e9cf 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -22,23 +22,17 @@ "types": "./lib/types/runner.d.ts", "default": "./lib/runner.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/runner.js", "lib/types-*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -46,7 +40,6 @@ "koffi": "^3.1.0" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts deleted file mode 100644 index 95b0555d49..0000000000 --- a/packages/sandbox/sandbox-windows-acl/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`. - * @module @deepseek-ai/dsh-sandbox-windows-acl/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-sandbox-windows-acl' - -/** Cordis companion plugin name. */ -export const name = 'sandbox-windows-acl-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or - * mutable data relation beyond the fail-closed contracts it enforces at each - * Win32 call boundary. - */ -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/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json index 97f5530dd8..bf3f21e195 100644 --- a/packages/sandbox/sandbox-windows-acl/tsconfig.json +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -15,9 +15,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../subprocess/win32-process" } diff --git a/packages/sandbox/sandbox-windows-acl/tsdown.config.ts b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts index 7de4ede1e1..a918b51a1d 100644 --- a/packages/sandbox/sandbox-windows-acl/tsdown.config.ts +++ b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsdown' // koffi stays an external native require — the same shape as // directory-picker-native's worker entry. export default defineConfig({ - entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', runner: 'lib/types/runner.js' }, + entry: { index: 'lib/types/index.js', runner: 'lib/types/runner.js' }, outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 642edc050e..3bc40e8804 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/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/README.md -README.md: 1e39fe31ad607a23a5a59f3e143b6823409126f2 -README.zh.md: 15d200ab6af5c6ff80091163624f5535799c6723 +README.md: 764ebd4c0de4286712006dbf37dc58a59351835a +README.zh.md: 05dc954717374f4e02c54b2b072b0f5b3acbf48c diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 1e39fe31ad..764ebd4c0d 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -93,7 +93,7 @@ This section explains the design decisions behind the contract and points at the | [`src/index.ts`](src/index.ts) | Plugin entry: `SandboxProvider` service, mode/enforcement/policy types, fail-closed error | | [`src/escalation.ts`](src/escalation.ts) | Escalation vocabulary: wider-mode ladder, argument validation, denial and hint markers, approval choreography | | [`src/roots.ts`](src/roots.ts) | Writable-root derivation shared by the Seatbelt profile and the in-process fs fence | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the abstract seam registers no event or data relation) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Escalation choreography diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index 15d200ab6a..05dc954717 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -93,7 +93,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`SandboxProvider` 服务、模式/强制执行/策略类型、故障关闭错误 | | [`src/escalation.ts`](src/escalation.ts) | 升权词汇:更宽模式阶梯、参数校验、拒绝与提示标记、审批编排 | | [`src/roots.ts`](src/roots.ts) | 可写根目录推导,Seatbelt profile 与进程内 fs 栅栏共享 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;抽象 seam 不注册事件或数据关系) | +| — | 不发布运行时不变式伴生入口;抽象 seam 不注册事件或数据关系。 | ### 升权编排 diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index fa2225eb83..8c8c52174e 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -18,27 +18,20 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts deleted file mode 100644 index 4d7f6dcdf0..0000000000 --- a/packages/sandbox/sandbox/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox`. - * @module @deepseek-ai/dsh-sandbox/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-sandbox' - -/** Cordis companion plugin name. */ -export const name = 'sandbox-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index 089fd90fd1..95245937ec 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 41852ca69d..059d5103a5 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/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/sdk/client/README.md -README.md: 176bddad402a21fd5cdc92aa0316a0716053dbfb -README.zh.md: c650dd7fa9498c30c32bc66141ab023354dc70c7 +README.md: 49898081640c700686b5a30b963180fa6b52717a +README.zh.md: de799585c2bec0c62eae31e7de52e6f921c8c96b diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 176bddad40..4989808164 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -76,7 +76,7 @@ The client is two layers over one wire: `DeepSeekHarness` (owned runs) over `Har | [`src/dispose.ts`](src/dispose.ts) | Private teardown ladder: stdin EOF → SIGTERM → SIGKILL to actual exit | | [`src/types.ts`](src/types.ts) | Launch and timeout options, notification shapes, `RunResult` | | [`src/index.ts`](src/index.ts) | Consumer interface: the two client layers and caller-facing types | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant — the peer is a separate runtime process) | +| — | No runtime invariant companion is published; this client library runs outside any harness context (its peer is a separate runtime process); the runtime's own packages own the event-stream relations. | ### Owned activity flow diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index c650dd7fa9..de799585c2 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -76,7 +76,7 @@ console.log(result.finalResponse) | [`src/dispose.ts`](src/dispose.ts) | 私有关闭阶梯:stdin EOF → SIGTERM → SIGKILL 直到真正退出 | | [`src/types.ts`](src/types.ts) | 启动与超时选项、通知结构、`RunResult` | | [`src/index.ts`](src/index.ts) | 消费方接口:两层客户端与面向调用方的类型 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式配套插件(无运行时不变式——对端是独立运行时进程) | +| — | 不发布运行时不变式伴生入口;对端是独立运行时进程。 | ### 自有活动流程 diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 1af6d101b6..c82aec22c2 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -18,15 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ], "license": "MIT", @@ -34,14 +29,12 @@ "@deepseek-ai/dsh": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/sdk/client/src/invariant.ts b/packages/sdk/client/src/invariant.ts deleted file mode 100644 index b93254ee9f..0000000000 --- a/packages/sdk/client/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`. - * @module @deepseek-ai/dsh-sdk-client/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-sdk-client' - -/** Cordis companion plugin name. */ -export const name = 'sdk-client-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this client library runs outside any harness context - * (its peer is a separate runtime process); the runtime's own packages own - * the event-stream relations. - */ -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/sdk/client/tsconfig.json b/packages/sdk/client/tsconfig.json index 1f14004f4a..01155cf3b0 100644 --- a/packages/sdk/client/tsconfig.json +++ b/packages/sdk/client/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../protocol" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 234306c500..daec09f7fa 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/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/sdk/protocol/README.md -README.md: 0bfdcc09c9d7835e7e1276d654c3563b705c16cd -README.zh.md: 84759bf945400d7541226e0ade8d600f6f4c62ab +README.md: ea99f83812e127992c4e32b959e9c3f011ad4e18 +README.zh.md: ecf6424ec222673008c559a6f43fac3b578573ff diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 0bfdcc09c9..ea99f83812 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -72,7 +72,7 @@ The package is built on one separation: a single newline-delimited transport cla | [`src/transport.ts`](src/transport.ts) | `JsonRpcLineTransport`: line framing, request/response/notification dispatch, error mapping, pending-request bookkeeping | | [`src/types.ts`](src/types.ts) | Named request/result and notification payload types, indexed by method | | [`src/index.ts`](src/index.ts) | Consumer interface: the transport and the named wire types | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant — a pure wire library owns no event stream) | +| — | No runtime invariant companion is published; a pure wire library (transport class + type declarations) with no event stream or mutable data relation of its own; both wire ends own their protocol behavior. | ### Frame dispatch diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index 84759bf945..ecf6424ec2 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -72,7 +72,7 @@ kind: "package-library" | [`src/transport.ts`](src/transport.ts) | `JsonRpcLineTransport`:行分帧、请求/响应/通知分发、错误映射、挂起请求记账 | | [`src/types.ts`](src/types.ts) | 具名请求/结果与通知载荷类型,按方法索引 | | [`src/index.ts`](src/index.ts) | 消费方接口:传输与具名协议类型 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式配套插件(无运行时不变式——纯协议库不持有事件流) | +| — | 不发布运行时不变式伴生入口;纯协议库不持有事件流。 | ### 帧分发 diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index a513b6ce2f..f1a761c0b0 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -18,27 +18,20 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/sdk/protocol/src/invariant.ts b/packages/sdk/protocol/src/invariant.ts deleted file mode 100644 index 948fb16d13..0000000000 --- a/packages/sdk/protocol/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-protocol`. - * @module @deepseek-ai/dsh-sdk-protocol/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-sdk-protocol' - -/** Cordis companion plugin name. */ -export const name = 'sdk-protocol-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a pure wire library (transport class + type - * declarations) with no event stream or mutable data relation of its own; - * both wire ends own their protocol behavior. - */ -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/sdk/protocol/tsconfig.json b/packages/sdk/protocol/tsconfig.json index dd07d29c8d..494fb20806 100644 --- a/packages/sdk/protocol/tsconfig.json +++ b/packages/sdk/protocol/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../../subagent/subagent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 7eae463d14..7bf4bd35f4 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: d5653543c5ca92f523470fd3b2360497e836f3e5 -README.zh.md: 6c30f274f666b83063df42d2c33253040ff9faf6 +README.md: c34874b4c1bf9163d5e33011373f1f35eb7f2a7a +README.zh.md: 0129d73fba0ddf5937a251fd6690c79b120204fb diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index d5653543c5..c34874b4c1 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -71,7 +71,7 @@ The plugin is a thin presentation adapter: [`HarnessSdkJsonRpcServer`](src/serve |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, stdio wiring, request dispatch, shared shutdown/exit task | | [`src/server.ts`](src/server.ts) | `HarnessSdkJsonRpcServer`: protocol methods, per-session agent creation, lifecycle subscriptions, teardown | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant — boundary and replay tests cover the protocol mapping) | +| — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. | ### Request flow diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 6c30f274f6..0129d73fba 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -71,7 +71,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、stdio 接线、请求分发、共享关闭/退出任务 | | [`src/server.ts`](src/server.ts) | `HarnessSdkJsonRpcServer`:协议方法、逐会话 agent 创建、生命周期订阅、清理 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式配套插件(无运行时不变式——边界与回放测试覆盖协议映射) | +| — | 不发布运行时不变式伴生入口;边界与回放测试覆盖协议映射。 | ### 请求流程 diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index e98fa97b75..dc2c79ef89 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -18,16 +18,11 @@ "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", @@ -37,7 +32,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -51,7 +45,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/sdk/server/src/invariant.ts b/packages/sdk/server/src/invariant.ts deleted file mode 100644 index 5867e3ad56..0000000000 --- a/packages/sdk/server/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-jsonrpc-server`. - * @module @deepseek-ai/dsh-sdk-jsonrpc-server/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-sdk-jsonrpc-server' - -/** Cordis companion plugin name. */ -export const name = 'sdk-jsonrpc-server-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this presentation adapter owns no durable package-local event stream; - * boundary and replay tests cover its protocol mapping. - */ -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/sdk/server/tsconfig.json b/packages/sdk/server/tsconfig.json index 0d70d71507..8bef96bf44 100644 --- a/packages/sdk/server/tsconfig.json +++ b/packages/sdk/server/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../subagent/subagent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index c8f260886d..9e36602226 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/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/session-query/session-log-export/README.md -README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d -README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34 +README.md: 9df6ce186f7508d7e5303a2df37b59ffb11851ec +README.zh.md: be3338a7eb890b61b05ad32b9af2bbd35f7df69b diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index a46bcdcd6f..9df6ce186f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -138,3 +138,5 @@ This Dev Note is working context for maintainers: open design questions and dire The download is deliberately browser-scoped; a Host-path or native folder export would need a new endpoint contract and a decision on where the ZIP lands. + +**Runtime invariant:** No companion is published. Connection and the command registry own both registrations, while each export reads authoritative Session services. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 348f6c09c8..be3338a7eb 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -138,3 +138,5 @@ Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/ 下载刻意限定在浏览器范围;Host 路径或原生文件夹导出需要新的端点约定,并决定 ZIP 的落盘位置。 + +**运行时不变式:** 不发布伴生入口。Connection 与 command registry 持有两个注册,每次 export 直接读取权威 Session service。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index f4a124c78b..1b3b609334 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -13,12 +13,11 @@ "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" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/client.js", "lib/types/**/*.d.ts"], "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }, "license": "MIT", "dependencies": { @@ -35,7 +34,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^" @@ -55,7 +53,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", diff --git a/packages/session-query/session-log-export/src/invariant.ts b/packages/session-query/session-log-export/src/invariant.ts deleted file mode 100644 index 0af82953e1..0000000000 --- a/packages/session-query/session-log-export/src/invariant.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Package invariant companion for `@deepseek-ai/dsh-session-log-export`. */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-log-export' - -export const name = 'session-export-invariant' -export const inject = ['invariants'] - -/** - * No runtime invariant: Connection and the command registry own both - * registrations, while each export reads authoritative Session services. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Host context carrying the invariant registry. - * @returns the registration 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/session-query/session-log-export/tests/invariant.host.spec.ts b/packages/session-query/session-log-export/tests/invariant.host.spec.ts deleted file mode 100644 index ee2d584ab3..0000000000 --- a/packages/session-query/session-log-export/tests/invariant.host.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { apply, inject, name } from '../src/invariant.ts' - -describe('@deepseek-ai/dsh-session-log-export/invariant', () => { - it('registers the package-owned empty companion', async () => { - const register = vi.fn(() => vi.fn()) - const ctx = new Context() - ctx.provide('invariants', { register }) - const dispose = await apply(ctx) - expect(name).toBe('session-export-invariant') - expect(inject).toEqual(['invariants']) - expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-session-log-export', expect.any(Function)) - dispose() - }) -}) diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 982a1360cb..4711213a69 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -7,8 +7,7 @@ }, "files": [ "src/archive.ts", - "src/index.ts", - "src/invariant.ts" + "src/index.ts" ], "references": [ { "path": "../../../vendor/cordis" }, @@ -16,7 +15,6 @@ { "path": "../../attachment/attachment" }, { "path": "../../core/session" }, { "path": "../../interaction/commands" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../session/session-persistence" }, { "path": "../session-query" } ] diff --git a/packages/session-query/session-log-export/tsdown.config.ts b/packages/session-query/session-log-export/tsdown.config.ts index 441876ff7b..f4a7087ff8 100644 --- a/packages/session-query/session-log-export/tsdown.config.ts +++ b/packages/session-query/session-log-export/tsdown.config.ts @@ -2,6 +2,6 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( '@deepseek-ai/dsh-session-log-export', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index e4f14642fe..4d4077bec2 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/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/session-query/session-query-sqlite/README.md -README.md: 5c839ebca39c6646122d1061592a9ac75b468e67 -README.zh.md: e3eaa2196b32f4ca553e6278e5598e92730b2ea5 +README.md: 179cf3fb433cc99128392e281e42e715e1bb1c52 +README.zh.md: f5f9e3eed4ca0d2e5598120e9cacf1988a0ef0c2 diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 5c839ebca3..179cf3fb43 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -97,7 +97,7 @@ The design history lives in the [SQLite FTS5 session search note](../../../.agen | [`src/index.ts`](src/index.ts) | Service: config, openAt lifecycle, serialized reconciliation, query execution, cursors | | [`src/query.ts`](src/query.ts) | Request normalization, parameterized predicates, snippets, predicate and binding budgets | | [`src/schema.ts`](src/schema.ts) | Database schema, application-id ownership, in-place reset, owner-only file creation | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; boundaries are validated per serialized query) | +| — | No runtime invariant companion is published; reconciliation, cursor generations, and derived-index ownership are validated at each serialized query boundary. | ### Index lifecycle diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index e3eaa2196b..f5f9e3eed4 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -97,7 +97,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 服务:配置、openAt 生命周期、串行化对账、查询执行、游标 | | [`src/query.ts`](src/query.ts) | 请求规范化、参数化谓词、摘录、谓词与绑定预算 | | [`src/schema.ts`](src/schema.ts) | 数据库 schema、application id 归属、原地重置、仅所有者文件创建 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;边界在每次串行化查询时校验) | +| — | 不发布运行时不变式伴生入口;边界在每次串行化查询时校验。 | ### 索引生命周期 diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4ec533bdd7..9975274f4f 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -18,21 +18,15 @@ "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/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", @@ -48,7 +42,6 @@ }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", diff --git a/packages/session-query/session-query-sqlite/src/invariant.ts b/packages/session-query/session-query-sqlite/src/invariant.ts deleted file mode 100644 index 6d9761807a..0000000000 --- a/packages/session-query/session-query-sqlite/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-query-sqlite`. - * @module @deepseek-ai/dsh-session-query-sqlite/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-session-query-sqlite' - -/** Cordis companion plugin name. */ -export const name = 'session-query-sqlite-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: reconciliation, cursor generations, and derived-index - * ownership are validated at each serialized query boundary. - */ -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/session-query/session-query-sqlite/tsconfig.json b/packages/session-query/session-query-sqlite/tsconfig.json index 35edfba9c4..a50870f290 100644 --- a/packages/session-query/session-query-sqlite/tsconfig.json +++ b/packages/session-query/session-query-sqlite/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../session/session-persistence" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../session-query" } diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index 3310244e2b..46ba6f606f 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/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/session-query/session-query/README.md -README.md: 95e823e8da256345516df331b6926b5ff663065d -README.zh.md: c816021ecbdaaffee906cb6db656a812fa640396 +README.md: e5729e95785df03ffa1d83ab99123f7fac681753 +README.zh.md: 52f0816256a641c9c94375c745c90dca8df24264 diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 95e823e8da..e5729e9578 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -95,7 +95,7 @@ The decision history lives in the [unified service decision](../../../.agents/no | [`src/documents.ts`](src/documents.ts) | Surface-aware semantic document projection | | [`src/tracing.ts`](src/tracing.ts) | One-shot session-lineage and event-relationship tracing | | [`src/sources.ts`](src/sources.ts) | Immutable-header compatibility check | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; results are per-call projections) | +| — | No runtime invariant companion is published; query results are immutable per-call projections whose lineage and event relations are validated while they are built; the service retains no observable result state. | ### Corpus resolution diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index c816021ecb..52f0816256 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -95,7 +95,7 @@ kind: "package-reference" | [`src/documents.ts`](src/documents.ts) | 表层感知的语义文档投影 | | [`src/tracing.ts`](src/tracing.ts) | 一次性会话血缘与事件关系追踪 | | [`src/sources.ts`](src/sources.ts) | 不可变 header 兼容性检查 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;结果均为按调用投影) | +| — | 不发布运行时不变式伴生入口;结果均为按调用投影。 | ### 语料库解析 diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 54dc7fa408..6998088710 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -18,22 +18,16 @@ "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-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -56,7 +50,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts deleted file mode 100644 index a264b4d279..0000000000 --- a/packages/session-query/session-query/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-query`. - * @module @deepseek-ai/dsh-session-query/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-session-query' - -/** Cordis companion plugin name. */ -export const name = 'session-query-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: query results are immutable per-call projections whose lineage and event - * relations are validated while they are built; the service retains no observable result state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 37fb5bf98c..ab19084ff9 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../session/session-projection-cache" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml index 7356d94828..6dc13bf23c 100644 --- a/packages/session-query/tool-session-query/README.i18n.yaml +++ b/packages/session-query/tool-session-query/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/session-query/tool-session-query/README.md -README.md: 6066c8466b0d161e80f96d1e667902ef30aa1d00 -README.zh.md: e509e2e8546cc535c56ddf5a6a4a1d608276a252 +README.md: 729e7c6874939093a1f1c6d273ca40a36187cc72 +README.zh.md: db2e3fa9f5d30150ae6fc66e44bac3bf7f1e1754 diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 6066c8466b..729e7c6874 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -184,3 +184,5 @@ This Dev Note is working context for maintainers: open design questions and dire Exact-string `cwd` equality is deliberately conservative; symlink-aware or canonical-path workspace identity would change which sessions share authority and is undecided. + +**Runtime invariant:** No companion is published. This read-only model adapter owns no event or mutable data relationship beyond the registries that already validate registration. diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md index e509e2e854..db2e3fa9f5 100644 --- a/packages/session-query/tool-session-query/README.zh.md +++ b/packages/session-query/tool-session-query/README.zh.md @@ -184,3 +184,5 @@ Use session_search to find relevant work from prior sessions, or session_event_s 字符串精确 `cwd` 相等是刻意保守的选择;符号链接感知或规范路径的工作区身份会改变哪些会话共享权限,尚未决定。 + +**运行时不变式:** 不发布伴生入口。这个只读模型 adapter 不持有其所属 registry 之外的事件或可变数据关系。 diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 81f21cddc4..be9c40bf4a 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", @@ -48,7 +42,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session-query/tool-session-query/src/invariant.ts b/packages/session-query/tool-session-query/src/invariant.ts deleted file mode 100644 index 3c8fd74114..0000000000 --- a/packages/session-query/tool-session-query/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-session-query`. - * @module @deepseek-ai/dsh-tool-session-query/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-tool-session-query' - -/** Cordis companion plugin name. */ -export const name = 'tool-session-query-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this read-only model adapter owns no event or mutable - * data relationship beyond the registries that already validate registration. - */ -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/session-query/tool-session-query/tsconfig.json b/packages/session-query/tool-session-query/tsconfig.json index d9accb9886..a7236af4b9 100644 --- a/packages/session-query/tool-session-query/tsconfig.json +++ b/packages/session-query/tool-session-query/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../session-query" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../util/timeout" }, diff --git a/packages/session/session-checkpoint-policy/README.i18n.yaml b/packages/session/session-checkpoint-policy/README.i18n.yaml index 15c765bd19..27e7ad16ee 100644 --- a/packages/session/session-checkpoint-policy/README.i18n.yaml +++ b/packages/session/session-checkpoint-policy/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/session/session-checkpoint-policy/README.md -README.md: 2f823ae2c2e1ec577ef82584197d1aadae4e8513 -README.zh.md: c53e64e40c4cf9e5f4f8bde33a2de170a172a785 +README.md: 76ee46a791123908815cb34a3fa0602c8e40645b +README.zh.md: 2302c7b70a66c050b430cb74cfd9bd115cdc6b79 diff --git a/packages/session/session-checkpoint-policy/README.md b/packages/session/session-checkpoint-policy/README.md index 2f823ae2c2..76ee46a791 100644 --- a/packages/session/session-checkpoint-policy/README.md +++ b/packages/session/session-checkpoint-policy/README.md @@ -70,7 +70,7 @@ The plugin is a listener-only composition over three seams, with no state of its | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `apply` installs the three checkpoint listeners | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; ordering is enforced at the intercepted seams) | +| — | No runtime invariant companion is published; checkpoint ordering is enforced at the intercepted waterfall and persistence seams; this stateless policy owns no independent mutable relation. | diff --git a/packages/session/session-checkpoint-policy/README.zh.md b/packages/session/session-checkpoint-policy/README.zh.md index c53e64e40c..2302c7b70a 100644 --- a/packages/session/session-checkpoint-policy/README.zh.md +++ b/packages/session/session-checkpoint-policy/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`apply` 安装三个检查点监听器 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;顺序由被拦截的 seam 强制) | +| — | 不发布运行时不变式伴生入口;顺序由被拦截的 seam 强制。 | diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index b95095d7f0..5efb6695d0 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -45,7 +39,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session/session-checkpoint-policy/src/invariant.ts b/packages/session/session-checkpoint-policy/src/invariant.ts deleted file mode 100644 index af12673ea1..0000000000 --- a/packages/session/session-checkpoint-policy/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-checkpoint-policy`. - * @module @deepseek-ai/dsh-session-checkpoint-policy/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-session-checkpoint-policy' - -/** Cordis companion plugin name. */ -export const name = 'session-checkpoint-policy-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: checkpoint ordering is enforced at the intercepted waterfall and - * persistence seams; this stateless policy owns no independent mutable relation. - */ -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/session/session-checkpoint-policy/tsconfig.json b/packages/session/session-checkpoint-policy/tsconfig.json index a2bfdf727b..d6cd3611b8 100644 --- a/packages/session/session-checkpoint-policy/tsconfig.json +++ b/packages/session/session-checkpoint-policy/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../session-persistence" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/tools" } diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index e4479686f6..6e9da8bc6c 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: 69fb2901d783c327878cd37570aec730a3ca0841 -README.zh.md: 188982028374d4ca11a5a658acabce5fe5df9930 +README.md: 74df08c1dfba3cb5d2665b56aecf138b57067a52 +README.zh.md: 7af38c69ae9903e0ceb6f321da75cb4fcbdc0a3b diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 69fb2901d7..74df08c1df 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -100,7 +100,7 @@ The default artifact is a standard concatenation of independent [Zstandard frame | [`src/format.ts`](src/format.ts) | Log path derivation, header encoding, record scanning, packed-row layout | | [`src/zstd.ts`](src/zstd.ts) | Zstandard frame compression, decoding, and frame scanning | | [`src/win32.ts`](src/win32.ts) | Windows write-through publish and directory creation | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; identity is enforced at the storage layer) | +| — | No runtime invariant companion is published; persistence correctness requires backend round-trip and crash-tail tests; this package exposes no continuously observable in-process relation. | diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 1889820283..7af38c69ae 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -100,7 +100,7 @@ kind: "package-reference" | [`src/format.ts`](src/format.ts) | 日志路径派生、header 编码、记录扫描、打包行布局 | | [`src/zstd.ts`](src/zstd.ts) | Zstandard 帧压缩、解码与帧扫描 | | [`src/win32.ts`](src/win32.ts) | Windows write-through 发布与目录创建 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;身份在存储层强制) | +| — | 不发布运行时不变式伴生入口;身份在存储层强制。 | diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 02b57ba93a..31011a6ae7 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -18,21 +18,15 @@ "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/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -42,7 +36,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/session/session-persistence-jsonl/src/invariant.ts b/packages/session/session-persistence-jsonl/src/invariant.ts deleted file mode 100644 index c48a083e8f..0000000000 --- a/packages/session/session-persistence-jsonl/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-jsonl`. - * @module @deepseek-ai/dsh-session-persistence-jsonl/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-session-persistence-jsonl' - -/** Cordis companion plugin name. */ -export const name = 'session-persistence-jsonl-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; - * this package exposes no continuously observable in-process relation. - */ -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/session/session-persistence-jsonl/tsconfig.json b/packages/session/session-persistence-jsonl/tsconfig.json index 2bb79c5919..ab6447e29f 100644 --- a/packages/session/session-persistence-jsonl/tsconfig.json +++ b/packages/session/session-persistence-jsonl/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../session-persistence" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml index 22923e8806..4f65425fe2 100644 --- a/packages/session/session-persistence-sqlite/README.i18n.yaml +++ b/packages/session/session-persistence-sqlite/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/session/session-persistence-sqlite/README.md -README.md: fc8e8eb7032eda475fceb065b80315421020a63f -README.zh.md: 88858ea07ff6bee54d29b8af947e610dc3879901 +README.md: c1f8bea7c7e30434d672bc01c43da1345f0d9e0a +README.zh.md: 3ca03bcf1744669f003090899554f32a1e20ecdf diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md index fc8e8eb703..c1f8bea7c7 100644 --- a/packages/session/session-persistence-sqlite/README.md +++ b/packages/session/session-persistence-sqlite/README.md @@ -110,7 +110,7 @@ The packed-row foundation lives in the [SQLite physical chunk-row decision](../. | [`src/codec.ts`](src/codec.ts) | Packing: which `assistant/chunk` runs become packed rows, size bounds | | [`src/compression.ts`](src/compression.ts) | Physical encoding: dictionary compression, sequence lists, row scan and decode | | [`src/sql.ts`](src/sql.ts) + [`resources/sql/`](resources/sql/) | Every SQL statement as a packaged, closed-name resource | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; packing is observable only by database round-trip) | +| — | No runtime invariant companion is published; physical packing is observable only by database round-trip and row-count checks, not a continuous in-process relation. | ### Database schema diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md index 88858ea07f..3ca03bcf17 100644 --- a/packages/session/session-persistence-sqlite/README.zh.md +++ b/packages/session/session-persistence-sqlite/README.zh.md @@ -110,7 +110,7 @@ await ctx.sessionPersistence.append(id, events) | [`src/codec.ts`](src/codec.ts) | 打包:哪些 `assistant/chunk` 连续段成为打包行、大小上限 | | [`src/compression.ts`](src/compression.ts) | 物理编码:字典压缩、序列列表、行扫描与解码 | | [`src/sql.ts`](src/sql.ts) + [`resources/sql/`](resources/sql/) | 所有 SQL 语句均为打包的闭名资源 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;打包只能通过数据库往返观察) | +| — | 不发布运行时不变式伴生入口;打包只能通过数据库往返观察。 | ### 数据库 schema diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 192f38b572..9a02d6c681 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -18,23 +18,17 @@ "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", "resources/zstd-dictionary.bin", "resources/sql/**/*.sql", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -46,7 +40,6 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts deleted file mode 100644 index 8436d5fbe5..0000000000 --- a/packages/session/session-persistence-sqlite/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-sqlite`. - * @module @deepseek-ai/dsh-session-persistence-sqlite/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-session-persistence-sqlite' - -/** Cordis companion plugin name. */ -export const name = 'session-persistence-sqlite-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: physical packing is observable only by database - * round-trip and row-count checks, not a continuous in-process relation. - */ -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/session/session-persistence-sqlite/tsconfig.json b/packages/session/session-persistence-sqlite/tsconfig.json index 8865e04321..69847d5ad9 100644 --- a/packages/session/session-persistence-sqlite/tsconfig.json +++ b/packages/session/session-persistence-sqlite/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../session-persistence" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 517f91b841..5f504470cc 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: fc47f0c6a6b5fa7ad9eb0f12db1c1baf6684de78 -README.zh.md: 6e6982e4f2cb6637d2f657dba88e40a4b7a21f27 +README.md: 36d9626e0cbfd3cd464078a62d7019d7a88fcbe8 +README.zh.md: d773bbebfbf4428221b8a9b72120d9bc5943a612 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index fc47f0c6a6..36d9626e0c 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -83,7 +83,7 @@ The package is the Service Definition of a capability seam with two halves. The | [`src/write-behind.ts`](src/write-behind.ts) | The per-session bounded write controller and flush barrier | | [`src/preparations.ts`](src/preparations.ts) | Bounded retention of unpublished Session preparations for resume reuse | | [`src/revision.ts`](src/revision.ts) | The branded opaque revision token | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the coordinator asserts stored/live identity and cwd) | +| — | No runtime invariant companion is published; persistence correctness requires backend round-trip and crash-tail tests; this package exposes no continuously observable in-process relation. | ### The write path at a glance diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 6e6982e4f2..d773bbebfb 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -83,7 +83,7 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi | [`src/write-behind.ts`](src/write-behind.ts) | 每会话有界写入控制器与 flush 屏障 | | [`src/preparations.ts`](src/preparations.ts) | 为恢复复用而有界保留的未发布 Session 准备结果 | | [`src/revision.ts`](src/revision.ts) | 带品牌类型的不透明修订值 token | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;协调器断言存储/活动身份与 cwd) | +| — | 不发布运行时不变式伴生入口;协调器断言存储/活动身份与 cwd。 | ### 写入路径概览 diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index ac73947e94..c5e80f1003 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -18,29 +18,22 @@ "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-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/session/session-persistence/src/invariant.ts b/packages/session/session-persistence/src/invariant.ts deleted file mode 100644 index 4259c1b065..0000000000 --- a/packages/session/session-persistence/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`. - * @module @deepseek-ai/dsh-session-persistence/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-session-persistence' - -/** Cordis companion plugin name. */ -export const name = 'session-persistence-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; - * this package exposes no continuously observable in-process relation. - */ -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/session/session-persistence/tsconfig.json b/packages/session/session-persistence/tsconfig.json index 04540fcac5..84c6f5ccb0 100644 --- a/packages/session/session-persistence/tsconfig.json +++ b/packages/session/session-persistence/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 59fe21bacf..3bab27725d 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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/session/session-projection-cache/README.md -README.md: c5e438a2ededef00a1faa456e9913492a603b51d -README.zh.md: eab0b81af618558e6f1e5e6bc0c40da3fcc38884 +README.md: 9d6c7fef235013dbeb72d7b3d8fbf7e8a3beb8ba +README.zh.md: b3fbc8a4ef48687a7c353dc4c00a5b731011979d diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index c5e438a2ed..9d6c7fef23 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -88,7 +88,7 @@ The cache stores one version-stamped document per session in the `session_projca |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionProjectionCache` service, write-behind listeners, cache reads | | [`src/spec.ts`](src/spec.ts) | The `session_projcache` domain spec and record identity types | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; correctness is enforced at the write and read paths) | +| — | No runtime invariant companion is published; the cache's correctness relation (a stored row equals the registry fold at its `seq` watermark) is only checkable by re-running the fold over the persisted log — duplicating the implementation rather than detecting drift — and its staleness is by design (fail-soft writes). The durable boundary is schema-validated by the cache's own zod parse on every read, and the read ladder's version/watermark guards are proven by the package spec. | diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index eab0b81af6..b3fbc8a4ef 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -88,7 +88,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionProjectionCache` 服务、写后监听器、缓存读取 | | [`src/spec.ts`](src/spec.ts) | `session_projcache` 域 spec 与记录身份类型 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;正确性在写入与读取路径强制) | +| — | 不发布运行时不变式伴生入口;正确性在写入与读取路径强制。 | diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index d6ab39e78f..493713655a 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -18,16 +18,11 @@ "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", @@ -37,14 +32,12 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", diff --git a/packages/session/session-projection-cache/src/invariant.ts b/packages/session/session-projection-cache/src/invariant.ts deleted file mode 100644 index 4a87631093..0000000000 --- a/packages/session/session-projection-cache/src/invariant.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`. - * @module @deepseek-ai/dsh-session-projection-cache/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-session-projection-cache' - -/** Cordis companion plugin name. */ -export const name = 'session-projection-cache-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the cache's correctness relation (a stored row equals - * the registry fold at its `seq` watermark) is only checkable by re-running the - * fold over the persisted log — duplicating the implementation rather than - * detecting drift — and its staleness is by design (fail-soft writes). The - * durable boundary is schema-validated by the cache's own zod parse on every - * read, and the read ladder's version/watermark guards are proven - * by the package spec. - */ -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/session/session-projection-cache/tsconfig.json b/packages/session/session-projection-cache/tsconfig.json index fd5c7ab456..4b68216bfd 100644 --- a/packages/session/session-projection-cache/tsconfig.json +++ b/packages/session/session-projection-cache/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../core/session" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../storage/storage" }, diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 6303cfad25..00abb609cc 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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/session/session-projection/README.md -README.md: c379abbfbdf145be4dcd7b4825ca349bc7a60f71 -README.zh.md: 17d38e612c778a64f228442f704335e0dc78562e +README.md: 9b9f26888ebe7cb06b5e706a7822906942c90245 +README.zh.md: 128b61b22a63db7f503b1f68f8c07223d02808bb diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index c379abbfbd..9b9f26888e 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -86,7 +86,7 @@ The package is the Service Definition and drive role of a capability seam: the f |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionProjectionRegistry` service, `ProjectionDefinition`, snapshot and checkpoint machinery | | [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; synchronous discipline is enforced by schema parse) | +| — | No runtime invariant companion is published; the registry's own contracts (duplicate-key and stateVersion rejection, effect-tied removal, the Object.is change gate) are enforced synchronously inside the service and proven by its spec, the drive relation (every committed `session/event` passes every unit) would require re-running the drive to check — duplicating the implementation rather than detecting drift — and the served-value relation (every served key has a live registration) lives on each carrier's wire path, which emits no cordis event this companion could observe; carrier specs assert it. Synchronous-unit discipline is enforced as far as practical by the boundary `schema.parse` (a Promise-returning view fails loudly). | ### Drive and checkpoint flow diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 17d38e612c..128b61b22a 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -86,7 +86,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionProjectionRegistry` 服务、`ProjectionDefinition`、快照与检查点机制 | | [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;同步纪律由 schema parse 强制) | +| — | 不发布运行时不变式伴生入口;同步纪律由 schema parse 强制。 | ### 驱动与检查点流程 diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 92a5155562..85b1e861a2 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,7 +27,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], @@ -40,12 +35,10 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/session/session-projection/src/invariant.ts b/packages/session/session-projection/src/invariant.ts deleted file mode 100644 index 537015d932..0000000000 --- a/packages/session/session-projection/src/invariant.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection`. - * @module @deepseek-ai/dsh-session-projection/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-session-projection' - -/** Cordis companion plugin name. */ -export const name = 'session-projection-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the registry's own contracts (duplicate-key and - * stateVersion rejection, effect-tied removal, the Object.is change gate) are - * enforced synchronously inside the service and proven by its spec, the - * drive relation (every committed `session/event` passes every unit) would - * require re-running the drive to check — duplicating the implementation - * rather than detecting drift — and the served-value relation (every served - * key has a live registration) lives on each carrier's wire path, which - * emits no cordis event this companion could observe; carrier specs assert - * it. Synchronous-unit discipline is enforced as far as practical by the - * boundary `schema.parse` (a Promise-returning view fails loudly). - */ -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/session/session-projection/tsconfig.json b/packages/session/session-projection/tsconfig.json index 3fde54a7ba..e817086a6a 100644 --- a/packages/session/session-projection/tsconfig.json +++ b/packages/session/session-projection/tsconfig.json @@ -16,9 +16,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml index 9d7b10440d..2c308a6ac8 100644 --- a/packages/session/session-stats/README.i18n.yaml +++ b/packages/session/session-stats/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/session/session-stats/README.md -README.md: fd5120de49f98c1d74cb7e07689a1474d20c54fa -README.zh.md: 6278fdbca593a3f624e9877f565fc4f1c569dc10 +README.md: 829c90ed12ea17b1d03be56f94dcb818df66c03a +README.zh.md: 6ce49ec864644c2cf2d0693daf2574cee771a732 diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md index fd5120de49..829c90ed12 100644 --- a/packages/session/session-stats/README.md +++ b/packages/session/session-stats/README.md @@ -130,3 +130,5 @@ These limits define what the figures describe and when the unit is absent. They None. + +**Runtime invariant:** No companion is published. The package owns a single pure projection fold whose wire payload is schema-validated by the projection registry at every snapshot and change-feed emission, and the event relations the fold relies on (`step/end` exactly once per entered step, monotonic host-assigned turn numbers, chunk and tool events carrying their step coordinates and call ids) are owned and runtime-checked by dsh-agent-loop and the session surface, not here. diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md index 6278fdbca5..6ce49ec864 100644 --- a/packages/session/session-stats/README.zh.md +++ b/packages/session/session-stats/README.zh.md @@ -130,3 +130,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。本包只有一个纯 projection fold,payload 每次都经 schema 校验;所依赖的 step、turn、chunk 与 tool event 关系由 agent-loop 和 session surface 检查。 diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 41b8ea424f..3d5a04f9be 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -35,13 +31,11 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -53,7 +47,6 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", diff --git a/packages/session/session-stats/src/invariant.ts b/packages/session/session-stats/src/invariant.ts deleted file mode 100644 index 582e5dcdb4..0000000000 --- a/packages/session/session-stats/src/invariant.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-stats`. - * @module @deepseek-ai/dsh-session-stats/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-session-stats' - -/** Cordis companion plugin name. */ -export const name = 'session-stats-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package owns a single pure projection fold whose - * wire payload is schema-validated by the projection registry at every - * snapshot and change-feed emission, and the event relations the fold relies - * on (`step/end` exactly once per entered step, monotonic host-assigned turn - * numbers, chunk and tool events carrying their step coordinates and call - * ids) are owned and runtime-checked by dsh-agent-loop and the session - * surface, not here. - */ -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/session/session-stats/tsconfig.json b/packages/session/session-stats/tsconfig.json index c87ec78f8b..9ab3552c2a 100644 --- a/packages/session/session-stats/tsconfig.json +++ b/packages/session/session-stats/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../llm/llm" }, diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index f5601caa0f..fd37d2a95f 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/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/session/session-telemetry-otel/README.md -README.md: 8aa215f5fd2e1344911e3f069f038a4f89f6fa3f -README.zh.md: 1bb3aa8f3b3e4b2f50e66e1833a8f4ab00112a8b +README.md: 6b0df3183b45406641c6da04dedf3b9ebe11186d +README.zh.md: e2df42a6e3d10ff4335275608636bfb41bf258a9 diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 8aa215f5fd..6b0df3183b 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -144,3 +144,5 @@ These limits define where SDK behavior governs and where export guarantees end. None. + +**Runtime invariant:** No companion is published. Mode selection changes capture handoff, SDK setup, and local diagnostics without mutating session or service state an independent companion can compare. Export remains inside the SDK past the backend boundary. diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 1bb3aa8f3b..e2df42a6e3 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -144,3 +144,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。mode 只改变 capture handoff、SDK setup 与本地 diagnostics,不改变可由独立 companion 对照的 Session 或 service 状态。 diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index c912bb2398..eb7b92d332 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -18,16 +18,11 @@ "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", @@ -42,7 +37,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", @@ -56,7 +50,6 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/session/session-telemetry-otel/src/invariant.ts b/packages/session/session-telemetry-otel/src/invariant.ts deleted file mode 100644 index 31864acb1c..0000000000 --- a/packages/session/session-telemetry-otel/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`. - * @module @deepseek-ai/dsh-session-telemetry-otel/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-session-telemetry-otel' - -/** Cordis companion plugin name. */ -export const name = 'session-telemetry-otel-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: mode selection changes capture handoff, SDK setup, and - * local diagnostics without mutating session or service state an independent - * companion can compare. Export remains inside the SDK past the backend boundary. - */ -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/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json index 65acdeb976..806fdabf18 100644 --- a/packages/session/session-telemetry-otel/tsconfig.json +++ b/packages/session/session-telemetry-otel/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../identity/anonymous-user-id" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index a0c5d093ab..26ab2ac73e 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/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/session/session-telemetry/README.md -README.md: 2970106b7b5f27ff2c88deb6c4327b5b8dc12466 -README.zh.md: 47f954e5d1f32d9db7a84bbd0fc38414e7af31c5 +README.md: 69fcaa76bb0d8146d419b4b69f93d70fa854afae +README.zh.md: e9c8266cc7012cfd4e18a3dd9b90fdd69cbca58c diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 2970106b7b..69fcaa76bb 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -125,3 +125,5 @@ These limits define the delivery and data-protection guarantees a deployment get None. + +**Runtime invariant:** No companion is published. The package's whole output is the backend handoff — a synchronous `emit()` call outside every authoritative event stream — and its capture side never appends session events, so no event/data relation exists for an independent companion to observe. diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index 47f954e5d1..e9c8266cc7 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -125,3 +125,5 @@ live 捕获通过组合方 fiber 的 effect 注册:`session/created` 收养会 无。 + +**运行时不变式:** 不发布伴生入口。全部输出都是权威事件流之外的同步 backend handoff,capture 端不追加 Session 事件,因此没有独立事件或数据关系。 diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index b89878c1d7..6f2e623cc1 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -18,28 +18,21 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/session/session-telemetry/src/invariant.ts b/packages/session/session-telemetry/src/invariant.ts deleted file mode 100644 index ec0928a868..0000000000 --- a/packages/session/session-telemetry/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`. - * @module @deepseek-ai/dsh-session-telemetry/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-session-telemetry' - -/** Cordis companion plugin name. */ -export const name = 'session-telemetry-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package's whole output is the backend handoff — a - * synchronous `emit()` call outside every authoritative event stream — and its - * capture side never appends session events, so no event/data relation exists - * for an independent companion to observe. - */ -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/session/session-telemetry/tsconfig.json b/packages/session/session-telemetry/tsconfig.json index 6fe33c1216..8b394c40da 100644 --- a/packages/session/session-telemetry/tsconfig.json +++ b/packages/session/session-telemetry/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../core/agent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/session/session-title-all-prompts-llm/README.i18n.yaml b/packages/session/session-title-all-prompts-llm/README.i18n.yaml index 1d66e346e6..09cf7d643d 100644 --- a/packages/session/session-title-all-prompts-llm/README.i18n.yaml +++ b/packages/session/session-title-all-prompts-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/session/session-title-all-prompts-llm/README.md -README.md: d8213753ac3ba83974c6968d5758e70f8154c3ee -README.zh.md: 0e1892e0a6d90935bfdc1e9f6a0a412ada5d8720 +README.md: 449542440df08e21372164e67f1de24eecbbf694 +README.zh.md: 3ce43f6c98c1ba70db8da1f10f787e357300decb diff --git a/packages/session/session-title-all-prompts-llm/README.md b/packages/session/session-title-all-prompts-llm/README.md index d8213753ac..449542440d 100644 --- a/packages/session/session-title-all-prompts-llm/README.md +++ b/packages/session/session-title-all-prompts-llm/README.md @@ -115,3 +115,5 @@ These limits define how the provider treats long and heterogeneous sessions. The None. + +**Runtime invariant:** No companion is published. This thin provider delegates request and result validation to the shared title service and LLM helper and retains no independent mutable state. diff --git a/packages/session/session-title-all-prompts-llm/README.zh.md b/packages/session/session-title-all-prompts-llm/README.zh.md index 0e1892e0a6..3ce43f6c98 100644 --- a/packages/session/session-title-all-prompts-llm/README.zh.md +++ b/packages/session/session-title-all-prompts-llm/README.zh.md @@ -115,3 +115,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。这个薄 provider 把 request/result 校验委托给共享 title service 与 LLM helper,不保留独立可变状态。 diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 34a4e201b1..e3a23a7898 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -18,16 +18,11 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -38,7 +33,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/packages/session/session-title-all-prompts-llm/src/invariant.ts b/packages/session/session-title-all-prompts-llm/src/invariant.ts deleted file mode 100644 index 780413719b..0000000000 --- a/packages/session/session-title-all-prompts-llm/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-title-all-prompts-llm`. - * @module @deepseek-ai/dsh-session-title-all-prompts-llm/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-session-title-all-prompts-llm' - -/** Cordis companion plugin name. */ -export const name = 'session-title-all-prompts-llm-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this thin provider delegates request and result validation to the shared - * title service and LLM helper and retains no independent mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session/session-title-all-prompts-llm/tsconfig.json b/packages/session/session-title-all-prompts-llm/tsconfig.json index 44e0d4ee33..2e120efa2c 100644 --- a/packages/session/session-title-all-prompts-llm/tsconfig.json +++ b/packages/session/session-title-all-prompts-llm/tsconfig.json @@ -6,7 +6,6 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../llm/llm" }, { "path": "../session-title" }, { "path": "../session-title-llm" } diff --git a/packages/session/session-title-first-prompt-llm/README.i18n.yaml b/packages/session/session-title-first-prompt-llm/README.i18n.yaml index c1408324bf..545d0c641a 100644 --- a/packages/session/session-title-first-prompt-llm/README.i18n.yaml +++ b/packages/session/session-title-first-prompt-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/session/session-title-first-prompt-llm/README.md -README.md: 29a01e8e2661890c16a798cc24ae982972df8312 -README.zh.md: 45b3d0e70de4579bd2248961c073ead5ae49e1a5 +README.md: b8823416f5d35f6ea4fd886d1ff99529974f2e2f +README.zh.md: 2868a98764d2e355b0303abe80535a85946dd72b diff --git a/packages/session/session-title-first-prompt-llm/README.md b/packages/session/session-title-first-prompt-llm/README.md index 29a01e8e26..b8823416f5 100644 --- a/packages/session/session-title-first-prompt-llm/README.md +++ b/packages/session/session-title-first-prompt-llm/README.md @@ -115,3 +115,5 @@ These limits define when this provider stops representing the session. They are None. + +**Runtime invariant:** No companion is published. This thin provider delegates request and result validation to the shared title service and LLM helper and retains no independent mutable state. diff --git a/packages/session/session-title-first-prompt-llm/README.zh.md b/packages/session/session-title-first-prompt-llm/README.zh.md index 45b3d0e70d..2868a98764 100644 --- a/packages/session/session-title-first-prompt-llm/README.zh.md +++ b/packages/session/session-title-first-prompt-llm/README.zh.md @@ -115,3 +115,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。这个薄 provider 把 request/result 校验委托给共享 title service 与 LLM helper,不保留独立可变状态。 diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index efd0c7d6dc..57fa30f2d2 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -18,16 +18,11 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -40,7 +35,6 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/session/session-title-first-prompt-llm/src/invariant.ts b/packages/session/session-title-first-prompt-llm/src/invariant.ts deleted file mode 100644 index 37d24ff667..0000000000 --- a/packages/session/session-title-first-prompt-llm/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-title-first-prompt-llm`. - * @module @deepseek-ai/dsh-session-title-first-prompt-llm/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-session-title-first-prompt-llm' - -/** Cordis companion plugin name. */ -export const name = 'session-title-first-prompt-llm-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this thin provider delegates request and result validation to the shared - * title service and LLM helper and retains no independent mutable state. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session/session-title-first-prompt-llm/tsconfig.json b/packages/session/session-title-first-prompt-llm/tsconfig.json index 44e0d4ee33..2e120efa2c 100644 --- a/packages/session/session-title-first-prompt-llm/tsconfig.json +++ b/packages/session/session-title-first-prompt-llm/tsconfig.json @@ -6,7 +6,6 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../llm/llm" }, { "path": "../session-title" }, { "path": "../session-title-llm" } diff --git a/packages/session/session-title-llm/README.i18n.yaml b/packages/session/session-title-llm/README.i18n.yaml index f7723a9db4..96846542d0 100644 --- a/packages/session/session-title-llm/README.i18n.yaml +++ b/packages/session/session-title-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/session/session-title-llm/README.md -README.md: d17c4962a3cc1f4f369013990c27ff1e5eea5588 -README.zh.md: 156600335eb8e72e39e7441039b6e69bc34528e4 +README.md: 7a2bb0849979f3cfaaea8433a793a72e22e4a639 +README.zh.md: eba42c4b9f27dddfed60519d54fd6599f658bc56 diff --git a/packages/session/session-title-llm/README.md b/packages/session/session-title-llm/README.md index d17c4962a3..7a2bb08499 100644 --- a/packages/session/session-title-llm/README.md +++ b/packages/session/session-title-llm/README.md @@ -127,3 +127,5 @@ These limits define the accepted generation shapes. They are current package con None. + +**Runtime invariant:** No companion is published. This stateless helper validates and freezes each auxiliary request before dispatch; deadline, stream, cited message seqs, and provider/model fields are checked synchronously and by tests. diff --git a/packages/session/session-title-llm/README.zh.md b/packages/session/session-title-llm/README.zh.md index 156600335e..eba42c4b9f 100644 --- a/packages/session/session-title-llm/README.zh.md +++ b/packages/session/session-title-llm/README.zh.md @@ -127,3 +127,5 @@ kind: "package-library" 无。 + +**运行时不变式:** 不发布伴生入口。这个无状态 helper 会在 dispatch 前校验并冻结每个辅助请求;deadline、stream、message seq、provider 与 model 由同步检查和测试覆盖。 diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 5be3cfc74d..7b4d51196d 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -43,7 +37,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/packages/session/session-title-llm/src/invariant.ts b/packages/session/session-title-llm/src/invariant.ts deleted file mode 100644 index 4c0a98e830..0000000000 --- a/packages/session/session-title-llm/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-title-llm`. - * @module @deepseek-ai/dsh-session-title-llm/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-session-title-llm' - -/** Cordis companion plugin name. */ -export const name = 'session-title-llm-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this stateless helper validates and freezes each auxiliary request before - * dispatch; deadline, stream, cited message seqs, and provider/model fields are checked synchronously and by tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session/session-title-llm/tsconfig.json b/packages/session/session-title-llm/tsconfig.json index 08a33c1735..f5578e6f37 100644 --- a/packages/session/session-title-llm/tsconfig.json +++ b/packages/session/session-title-llm/tsconfig.json @@ -9,7 +9,6 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../llm/llm" }, { "path": "../../util/timeout" }, { "path": "../session-title" } diff --git a/packages/settings/settings-file/README.i18n.yaml b/packages/settings/settings-file/README.i18n.yaml index 17ab2b0cb6..9bcf6b5277 100644 --- a/packages/settings/settings-file/README.i18n.yaml +++ b/packages/settings/settings-file/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/settings/settings-file/README.md -README.md: 348441aa5cdce219dcf87abdd1c8ba9f9ce74599 -README.zh.md: 809cda2ad8066ffeb210ecf5cfa6163f7228e7ab +README.md: ea578a1739e1bd5d7b7f47be596998b124337ed5 +README.zh.md: 376d985a454e90402bddd7664e343bfe7df23c7c diff --git a/packages/settings/settings-file/README.md b/packages/settings/settings-file/README.md index 348441aa5c..ea578a1739 100644 --- a/packages/settings/settings-file/README.md +++ b/packages/settings/settings-file/README.md @@ -90,7 +90,7 @@ This section explains the design decisions behind the provider and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Provider: spec resolution, load/parse, read-modify-write under the writer lock, watcher lifecycle, YAML/JSON rendering | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; file round-trip, watcher timing, and atomic-write behavior are proven by package tests, and the in-process commit relation is owned by `dsh-settings`) | +| — | No runtime invariant companion is published; this provider's contracts are file round-trip, watcher timing, and atomic-write behavior — IO effects proven by package tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. | ### Document lifecycle diff --git a/packages/settings/settings-file/README.zh.md b/packages/settings/settings-file/README.zh.md index 809cda2ad8..376d985a45 100644 --- a/packages/settings/settings-file/README.zh.md +++ b/packages/settings/settings-file/README.zh.md @@ -90,7 +90,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 提供方:spec 解析、加载/解析、写锁下的读-改-写、watcher 生命周期、YAML/JSON 渲染 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;文件往返、watcher 时序与原子写入行为由包测试证明,进程内提交关系归 `dsh-settings` 所有) | +| — | 不发布运行时不变式伴生入口;文件往返、watcher 时序与原子写入行为由包测试证明,进程内提交关系归 `dsh-settings` 所有。 | ### 文档生命周期 diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 71229de2af..3c8e459c8c 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -18,22 +18,16 @@ "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-atomic-write": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -45,7 +39,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/settings/settings-file/src/invariant.ts b/packages/settings/settings-file/src/invariant.ts deleted file mode 100644 index 1b173a9c99..0000000000 --- a/packages/settings/settings-file/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-settings-file`. - * @module @deepseek-ai/dsh-settings-file/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-settings-file' - -/** Cordis companion plugin name. */ -export const name = 'settings-file-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this provider's contracts are file round-trip, - * watcher timing, and atomic-write behavior — IO effects proven by package - * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. - */ -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/settings/settings-file/tsconfig.json b/packages/settings/settings-file/tsconfig.json index 6a73bec2a6..25bb9d13e2 100644 --- a/packages/settings/settings-file/tsconfig.json +++ b/packages/settings/settings-file/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 81134a7ef2..806aa7549c 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-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/shell/bash-local/README.md -README.md: 1523430aa27c5b07d6dd1db614a697a1c1738cc2 -README.zh.md: dc5200e764623f8482b9e07f21bed300f0895c2e +README.md: df9248651c94b26df3a16a19a360056a5a9199e1 +README.zh.md: ad5ba2143f0b0eff46264de2be48bd1bb1067edf diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 1523430aa2..df9248651c 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -87,7 +87,7 @@ The executor is a Service Provider for the `ctx.shell` seam built on the subproc | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `LocalBashExecutor`, `Config`, settings-section wiring | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the owning seam) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | | `tests/executor.spec.ts` | Exercised behavior: budgets, classification, background handles, ownership | | `tests/settings.spec.ts` | Settings layering over the composition entry | diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index dc5200e764..ad5ba2143f 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -87,7 +87,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`LocalBashExecutor`、`Config`、设置段接线 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在所属 seam 处执行) | +| — | 不发布运行时不变式伴生入口;约定在所属 seam 处执行。 | | `tests/executor.spec.ts` | 已演练的行为:预算、分类、后台句柄、归属 | | `tests/settings.spec.ts` | 设置段叠加在组合条目之上 | diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index 9887ebad75..850c9d7a0b 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -18,22 +18,16 @@ "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-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -44,7 +38,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/shell/bash-local/src/invariant.ts b/packages/shell/bash-local/src/invariant.ts deleted file mode 100644 index 55905cbf5e..0000000000 --- a/packages/shell/bash-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-bash-local`. - * @module @deepseek-ai/dsh-bash-local/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-bash-local' - -/** Cordis companion plugin name. */ -export const name = 'bash-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/shell/bash-local/tsconfig.json b/packages/shell/bash-local/tsconfig.json index 14b4cdefc0..5097714846 100644 --- a/packages/shell/bash-local/tsconfig.json +++ b/packages/shell/bash-local/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/bash-sandbox/README.i18n.yaml b/packages/shell/bash-sandbox/README.i18n.yaml index 05f7a32787..9965d94905 100644 --- a/packages/shell/bash-sandbox/README.i18n.yaml +++ b/packages/shell/bash-sandbox/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/shell/bash-sandbox/README.md -README.md: 8ec918ed3e361f6a2c6345d1cda25e5c0bd39560 -README.zh.md: 4c44a0ad6d09d16c18f75fe237f91116f7a62fcd +README.md: db0b280c595458873537479700a69120f2865994 +README.zh.md: db5019c9dc5b4e8f99caaad4374f6b83a7b4288f diff --git a/packages/shell/bash-sandbox/README.md b/packages/shell/bash-sandbox/README.md index 8ec918ed3e..db0b280c59 100644 --- a/packages/shell/bash-sandbox/README.md +++ b/packages/shell/bash-sandbox/README.md @@ -83,7 +83,7 @@ The executor is the sandboxing Service Provider for the `ctx.shell` seam: it inh |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SandboxBashExecutor`, per-process fact retention, run/start wrapping | | [`src/helpers.ts`](src/helpers.ts) | Denial, runner-failure, and runner-spawn-failure classification | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; classification is observable in results) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | | `tests/` | Exercised behavior across the bwrap, Landlock, and Seatbelt runners | ### Main flow diff --git a/packages/shell/bash-sandbox/README.zh.md b/packages/shell/bash-sandbox/README.zh.md index 4c44a0ad6d..db5019c9dc 100644 --- a/packages/shell/bash-sandbox/README.zh.md +++ b/packages/shell/bash-sandbox/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SandboxBashExecutor`、按进程保留事实、run/start 包装 | | [`src/helpers.ts`](src/helpers.ts) | 拒绝、runner 失败与 runner spawn 失败分类 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;分类在结果中可观察) | +| — | 不发布运行时不变式伴生入口;分类在结果中可观察。 | | `tests/` | 跨 bwrap、Landlock 与 Seatbelt runner 演练的行为 | ### 主要流程 diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 40454620a8..81eaefaab5 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -18,23 +18,17 @@ "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-shell": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -42,7 +36,6 @@ "devDependencies": { "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/shell/bash-sandbox/src/invariant.ts b/packages/shell/bash-sandbox/src/invariant.ts deleted file mode 100644 index 4592d633af..0000000000 --- a/packages/shell/bash-sandbox/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-bash-sandbox`. - * @module @deepseek-ai/dsh-bash-sandbox/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-bash-sandbox' - -/** Cordis companion plugin name. */ -export const name = 'bash-sandbox-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/shell/bash-sandbox/tsconfig.json b/packages/shell/bash-sandbox/tsconfig.json index b0bfcab908..5a2d00fe3c 100644 --- a/packages/shell/bash-sandbox/tsconfig.json +++ b/packages/shell/bash-sandbox/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../shell/bash-local" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index 2da1533c41..39ed34f989 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-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/shell/pwsh-local/README.md -README.md: 0f586495e9ab9c1bb533d2dce26f2777ef859bc8 -README.zh.md: e704ee6d2f55fd72143b1c50da4b7b0162ff830e +README.md: d9ad46b84f78a2a8ed373420039646e710b79710 +README.zh.md: ef4f7e376dbad2ad1878b45dae156b6afb312783 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 0f586495e9..d9ad46b84f 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -93,7 +93,7 @@ The executor is the PowerShell Service Provider for the `ctx.shell` seam built o |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `PwshLocalExecutor`, `Config`, settings wiring, argv seam | | [`src/resolve.ts`](src/resolve.ts) | Pure `resolvePwshPath`/`candidatePwshPaths` executable resolution | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the owning seam) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | | `tests/` | Exercised behavior: budgets, classification, resolution, background handles | ### Main flow diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index e704ee6d2f..ef4f7e376d 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -93,7 +93,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`PwshLocalExecutor`、`Config`、设置接线、argv seam | | [`src/resolve.ts`](src/resolve.ts) | 纯函数 `resolvePwshPath`/`candidatePwshPaths` 可执行文件解析 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在所属 seam 处执行) | +| — | 不发布运行时不变式伴生入口;约定在所属 seam 处执行。 | | `tests/` | 已演练的行为:预算、分类、解析、后台句柄 | ### 主要流程 diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 65831007e6..ea5ff2a120 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -18,22 +18,16 @@ "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-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -44,7 +38,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/shell/pwsh-local/src/invariant.ts b/packages/shell/pwsh-local/src/invariant.ts deleted file mode 100644 index 9b6db9426b..0000000000 --- a/packages/shell/pwsh-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`. - * @module @deepseek-ai/dsh-pwsh-local/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-pwsh-local' - -/** Cordis companion plugin name. */ -export const name = 'pwsh-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/shell/pwsh-local/tsconfig.json b/packages/shell/pwsh-local/tsconfig.json index 14b4cdefc0..5097714846 100644 --- a/packages/shell/pwsh-local/tsconfig.json +++ b/packages/shell/pwsh-local/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/pwsh-sandbox/README.i18n.yaml b/packages/shell/pwsh-sandbox/README.i18n.yaml index abf9e9e709..b1e6cb24ab 100644 --- a/packages/shell/pwsh-sandbox/README.i18n.yaml +++ b/packages/shell/pwsh-sandbox/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/shell/pwsh-sandbox/README.md -README.md: 49cadd35a9f4311cfa4188df6e555e180e3486aa -README.zh.md: 20595f7f10c38a9bff77666dab014314895b97f7 +README.md: 1c839142f9c16da04ad91430fc2fd320e155f96e +README.zh.md: dcf0edfaae1cbb3c0079237b3214e7cc5b186f2a diff --git a/packages/shell/pwsh-sandbox/README.md b/packages/shell/pwsh-sandbox/README.md index 49cadd35a9..1c839142f9 100644 --- a/packages/shell/pwsh-sandbox/README.md +++ b/packages/shell/pwsh-sandbox/README.md @@ -83,7 +83,7 @@ The executor is the pwsh twin of `dsh-bash-sandbox`: it inherits `dsh-pwsh-local |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SandboxPwshExecutor`, per-process fact retention, run/start wrapping | | [`src/helpers.ts`](src/helpers.ts) | Denial, runner-failure, and runner-spawn-failure classification | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; classification is observable in results) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seams. | | `tests/` | Exercised behavior across the ACL and platform runners | ### Main flow diff --git a/packages/shell/pwsh-sandbox/README.zh.md b/packages/shell/pwsh-sandbox/README.zh.md index 20595f7f10..dcf0edfaae 100644 --- a/packages/shell/pwsh-sandbox/README.zh.md +++ b/packages/shell/pwsh-sandbox/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SandboxPwshExecutor`、按进程保留事实、run/start 包装 | | [`src/helpers.ts`](src/helpers.ts) | 拒绝、runner 失败与 runner spawn 失败分类 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;分类在结果中可观察) | +| — | 不发布运行时不变式伴生入口;分类在结果中可观察。 | | `tests/` | 跨 ACL 与平台 runner 演练的行为 | ### 主要流程 diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index ce1f40ebc6..d1fa03ec6e 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -18,22 +18,16 @@ "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-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -41,7 +35,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/shell/pwsh-sandbox/src/invariant.ts b/packages/shell/pwsh-sandbox/src/invariant.ts deleted file mode 100644 index 9d229d1f0d..0000000000 --- a/packages/shell/pwsh-sandbox/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`. - * @module @deepseek-ai/dsh-pwsh-sandbox/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-pwsh-sandbox' - -/** Cordis companion plugin name. */ -export const name = 'pwsh-sandbox-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or - * mutable data relation beyond contracts enforced at its owning seams. - */ -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/shell/pwsh-sandbox/tsconfig.json b/packages/shell/pwsh-sandbox/tsconfig.json index fcfe7fa840..cb87738ee9 100644 --- a/packages/shell/pwsh-sandbox/tsconfig.json +++ b/packages/shell/pwsh-sandbox/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../shell/pwsh-local" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/shell-env/README.i18n.yaml b/packages/shell/shell-env/README.i18n.yaml index ffa237ffd7..cbfe47cb5c 100644 --- a/packages/shell/shell-env/README.i18n.yaml +++ b/packages/shell/shell-env/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/shell/shell-env/README.md -README.md: 3a5992264cb04ceda5b0726de0a0fdbfdbd001d2 -README.zh.md: 94ed15e34e5b730b3268bb796d6e6a8d51cc92f0 +README.md: 0004e0c73321b73a61c4ffa64ad45fc13a290231 +README.zh.md: ba923d644c85c7e150e24d1440c78c152c5fac88 diff --git a/packages/shell/shell-env/README.md b/packages/shell/shell-env/README.md index 3a5992264c..0004e0c733 100644 --- a/packages/shell/shell-env/README.md +++ b/packages/shell/shell-env/README.md @@ -87,7 +87,7 @@ This section explains the design decisions behind the registry and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry, `ShellEnvRegistry` service, built-in facts and the persistence contributor | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; collection is observable through tool execution) | +| — | No runtime invariant companion is published; the environment registry validates ownership and collected values at each registration/collection; it publishes no independent snapshot that a companion could cross-check. | ### Collection diff --git a/packages/shell/shell-env/README.zh.md b/packages/shell/shell-env/README.zh.md index 94ed15e34e..ba923d644c 100644 --- a/packages/shell/shell-env/README.zh.md +++ b/packages/shell/shell-env/README.zh.md @@ -87,7 +87,7 @@ contributor 必须声明它返回的每个键;返回未声明或非字符串 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口、`ShellEnvRegistry` 服务、内置事实与持久化 contributor | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;收集可通过工具执行观察) | +| — | 不发布运行时不变式伴生入口;收集可通过工具执行观察。 | ### 收集 diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 048972a734..d60fa4cf0d 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -18,22 +18,16 @@ "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-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -45,7 +39,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/shell/shell-env/src/invariant.ts b/packages/shell/shell-env/src/invariant.ts deleted file mode 100644 index 7968371c2d..0000000000 --- a/packages/shell/shell-env/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-shell-env`. - * @module @deepseek-ai/dsh-shell-env/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-shell-env' - -/** Cordis companion plugin name. */ -export const name = 'shell-env-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the environment registry validates ownership and collected values at each - * registration/collection; it publishes no independent snapshot that a companion could cross-check. - */ -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/shell/shell-env/tsconfig.json b/packages/shell/shell-env/tsconfig.json index 52978a303e..fa1cbc5430 100644 --- a/packages/shell/shell-env/tsconfig.json +++ b/packages/shell/shell-env/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../../session/session-persistence" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index 4095071e42..68001f5d8e 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/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/shell/shell/README.md -README.md: 1400947fc7cda0d45a050e45008356aebbd33776 -README.zh.md: bde496672866bb182f883d0fe0d8d793624cfa49 +README.md: dd3eeb45af432e6fdbb9a0aa688d8a5c3c58bbb1 +README.zh.md: 8767aad9795494c6a996c7063778b1a816362e16 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index 1400947fc7..dd3eeb45af 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -83,7 +83,7 @@ The package is one role of a standard capability seam: the Service Definition th | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `ShellExecutor` service and the shared settings namespace | | [`src/types.ts`](src/types.ts) | Request/spec vocabulary, `ShellRunResult`, `ShellProcess`, and sandbox facts | | [`src/render.ts`](src/render.ts) | `parseExitStatus`: the exit-status marker contract the shell tools share | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; executors and policy own observations) | +| — | No runtime invariant companion is published; this stateless Service Definition owns request/result types, while executors and policy own observations. | ### Settings namespace diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index bde4966728..8767aad979 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -83,7 +83,7 @@ seam 本身不是执行器:每个组合只挂载一个提供方,工具即可 | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `ShellExecutor` 服务与共享设置命名空间 | | [`src/types.ts`](src/types.ts) | 请求/spec 词汇、`ShellRunResult`、`ShellProcess` 与沙箱事实 | | [`src/render.ts`](src/render.ts) | `parseExitStatus`:shell 工具共享的退出状态标记约定 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;执行器与策略负责观察) | +| — | 不发布运行时不变式伴生入口;执行器与策略负责观察。 | ### 设置命名空间 diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index c05a9a2e04..7dfd5de3e7 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -18,28 +18,21 @@ "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/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/shell/shell/src/invariant.ts b/packages/shell/shell/src/invariant.ts deleted file mode 100644 index d9be933721..0000000000 --- a/packages/shell/shell/src/invariant.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-shell/invariant */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-shell' - -/** Cordis companion plugin name. */ -export const name = 'shell-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: this stateless Service Definition owns request/result types, while executors and policy own observations. */ -const install: InvariantInstaller = () => {} - -/** - * Register the bash 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)) diff --git a/packages/shell/shell/tsconfig.json b/packages/shell/shell/tsconfig.json index 6272c5dd59..380470c4b2 100644 --- a/packages/shell/shell/tsconfig.json +++ b/packages/shell/shell/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index 050d349cad..56c1a3a330 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/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/shell/tool-bash-persistent/README.md -README.md: e7e75c49ecf3249b13ad28180420a9e980b8f0cb -README.zh.md: 80831e3516199068c52e26d7d1a2fdc64a2907bc +README.md: 8aaae0f848f1b1558cb05095ed3083c17f29f0bb +README.zh.md: 6072985023605cf12d53c134c5616a5ef111097b diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index e7e75c49ec..8aaae0f848 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -80,7 +80,7 @@ This section explains the design decisions behind the tool and points at the cod | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: shell registry, command wrapping, scrollback polling, extraction and rendering | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; shell reuse is observable through tool execution) | +| — | No runtime invariant companion is published; the adapter's private owner-to-shell cache has no observable event or data relation. Lifecycle tests prove its cleanup without adding a public API solely for an invariant. | ### Command flow diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index 80831e3516..6072985023 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -80,7 +80,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:shell 注册表、命令包装、scrollback 轮询、提取与渲染 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;shell 复用可通过工具执行观察) | +| — | 不发布运行时不变式伴生入口;shell 复用可通过工具执行观察。 | ### 命令流程 diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index d72697c5f2..55554aff24 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -18,21 +18,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -45,7 +39,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", diff --git a/packages/shell/tool-bash-persistent/src/invariant.ts b/packages/shell/tool-bash-persistent/src/invariant.ts deleted file mode 100644 index 9cfa0423d5..0000000000 --- a/packages/shell/tool-bash-persistent/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`. - * @module @deepseek-ai/dsh-tool-bash-persistent/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-tool-bash-persistent' - -/** Cordis companion plugin name. */ -export const name = 'tool-bash-persistent-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the adapter's private owner-to-shell cache has no - * observable event or data relation. Lifecycle tests prove its cleanup without - * adding a public API solely for an invariant. - */ -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/shell/tool-bash-persistent/tsconfig.json b/packages/shell/tool-bash-persistent/tsconfig.json index 463d3803a5..fe64fdde1f 100644 --- a/packages/shell/tool-bash-persistent/tsconfig.json +++ b/packages/shell/tool-bash-persistent/tsconfig.json @@ -7,10 +7,10 @@ "include": ["src"], "references": [ { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, { "path": "../../terminal/terminal" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../util/timeout" } ] } diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index ddf72e2b4f..7177abb59b 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-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/shell/tool-bash/README.md -README.md: 0b3d60af52c23575b8eafe7edb0fc026359dde8f -README.zh.md: 2fe8a4c2d19669449b521b98a9243013aefb3829 +README.md: a8cd3af0de6abcee46ec5e375d0b58674a372023 +README.zh.md: 9d8ad57e682cba3238836a2b5bfed506ffc02937 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 0b3d60af52..a8cd3af0de 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -89,7 +89,7 @@ This section explains the design decisions behind the tool and points at the cod | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, prompt section, arg validation, escalation, request assembly | | [`src/background.ts`](src/background.ts) | Map a settled background process onto generic job outcome vocabulary | | [`src/render.ts`](src/render.ts) | Model-facing result text: streams, markers, truncation notices | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; execution relations are owned by the capability seam) | +| — | No runtime invariant companion is published; the environment registry validates ownership and collected values at each mutation/read; it publishes no independent snapshot that a companion could cross-check. | ### Request resolution diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 2fe8a4c2d1..9d8ad57e68 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -89,7 +89,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、提示词区段、参数校验、升权、请求组装 | | [`src/background.ts`](src/background.ts) | 把已结算的后台进程映射为通用任务结果词汇 | | [`src/render.ts`](src/render.ts) | 模型侧结果文本:流、标记、截断通知 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;执行关系归能力 seam 所有) | +| — | 不发布运行时不变式伴生入口;执行关系归能力 seam 所有。 | ### 请求解析 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 7e8d189644..bc10e60ff8 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -18,16 +18,11 @@ "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", @@ -35,7 +30,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -55,7 +49,6 @@ "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", diff --git a/packages/shell/tool-bash/src/invariant.ts b/packages/shell/tool-bash/src/invariant.ts deleted file mode 100644 index a4ce0c34eb..0000000000 --- a/packages/shell/tool-bash/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash`. - * @module @deepseek-ai/dsh-tool-bash/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-tool-bash' - -/** Cordis companion plugin name. */ -export const name = 'tool-bash-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the environment registry validates ownership and collected values at each - * mutation/read; it publishes no independent snapshot that a companion could cross-check. - */ -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/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 304d983a95..aee17a276f 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -44,9 +44,6 @@ { "path": "../../sandbox/sandbox" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../sandbox/sandbox-policy" } diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index a7eb0a6e0e..951526bf88 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/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/shell/tool-pwsh-persistent/README.md -README.md: 74bc23bcb583bb65cc4921d9ba01bb8796809dc8 -README.zh.md: f5e170a2d58fd7eac0d7330764ae1ba61450b4b1 +README.md: 4865c7482d29369f65c2c41323f69a3145bb2536 +README.zh.md: 92826668173c5ba7a1588bba8ba13078937d3d9d diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 74bc23bcb5..4865c7482d 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -82,7 +82,7 @@ This section explains the design decisions behind the tool and points at the cod | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: shell registry, prompt setup, command wrapping, scrollback polling, extraction and rendering | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; shell reuse is observable through tool execution) | +| — | No runtime invariant companion is published; the adapter's private owner-to-shell cache has no observable event or data relation. Lifecycle tests prove its cleanup without adding a public API solely for an invariant. | ### Command flow diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index f5e170a2d5..9282666817 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -82,7 +82,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:shell 注册表、prompt 设置、命令包装、scrollback 轮询、提取与渲染 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;shell 复用可通过工具执行观察) | +| — | 不发布运行时不变式伴生入口;shell 复用可通过工具执行观察。 | ### 命令流程 diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 2c7519147d..0f2f5b417e 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -18,21 +18,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -45,7 +39,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", diff --git a/packages/shell/tool-pwsh-persistent/src/invariant.ts b/packages/shell/tool-pwsh-persistent/src/invariant.ts deleted file mode 100644 index 6f436019f2..0000000000 --- a/packages/shell/tool-pwsh-persistent/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`. - * @module @deepseek-ai/dsh-tool-pwsh-persistent/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-tool-pwsh-persistent' - -/** Cordis companion plugin name. */ -export const name = 'tool-pwsh-persistent-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the adapter's private owner-to-shell cache has no - * observable event or data relation. Lifecycle tests prove its cleanup without - * adding a public API solely for an invariant. - */ -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/shell/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json index 42ce584d6a..fe64fdde1f 100644 --- a/packages/shell/tool-pwsh-persistent/tsconfig.json +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -11,7 +11,6 @@ { "path": "../../core/agent" }, { "path": "../../core/tools" }, { "path": "../../terminal/terminal" }, - { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../util/timeout" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 1ecd4a26e2..a319e6eba0 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/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/shell/tool-pwsh/README.md -README.md: f4de57c2c30df4f0af5d396f7e0ba0a4c2fa37c8 -README.zh.md: ef002f055813e99ab39a655282a8c4b1dd15621d +README.md: e2bc4672e48d592e40464a53b5e43c139193928f +README.zh.md: 2eaf1ca19a94fac71eeb19e62ba5e21cd5c989f9 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index f4de57c2c3..e2bc4672e4 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the tool and points at the cod | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, prompt section, arg validation, escalation, request assembly | | [`src/background.ts`](src/background.ts) | Map a settled background process onto generic job outcome vocabulary | | [`src/render.ts`](src/render.ts) | Model-facing result text: streams, markers, truncation notices (bash twin) | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; execution relations are owned by the capability seam) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Rendering and exit markers diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index ef002f0558..2eaf1ca19a 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、提示词区段、参数校验、升权、请求组装 | | [`src/background.ts`](src/background.ts) | 把已结算的后台进程映射为通用任务结果词汇 | | [`src/render.ts`](src/render.ts) | 模型侧结果文本:流、标记、截断通知(bash 孪生) | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;执行关系归能力 seam 所有) | +| — | 不发布运行时不变式伴生入口;执行关系归能力 seam 所有。 | ### 渲染与退出标记 diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index d2ad23d985..23248ef34c 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -18,16 +18,11 @@ "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", @@ -35,7 +30,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -54,7 +48,6 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", diff --git a/packages/shell/tool-pwsh/src/invariant.ts b/packages/shell/tool-pwsh/src/invariant.ts deleted file mode 100644 index aa53743ba7..0000000000 --- a/packages/shell/tool-pwsh/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`. - * @module @deepseek-ai/dsh-tool-pwsh/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-tool-pwsh' - -/** Cordis companion plugin name. */ -export const name = 'tool-pwsh-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 59a4d9478c..b2dd6ddcc6 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -49,9 +49,6 @@ }, { "path": "../../sandbox/sandbox-policy" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/skill/skill-badge/README.i18n.yaml b/packages/skill/skill-badge/README.i18n.yaml index 8396772441..59170f3c20 100644 --- a/packages/skill/skill-badge/README.i18n.yaml +++ b/packages/skill/skill-badge/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/skill/skill-badge/README.md -README.md: fe5c97af8b7bfb149de998402825092d770b3cef -README.zh.md: e489f1330621436dbd4402d410b797d59b04affa +README.md: a51db5d8946f3c9f563253385ac939b0a84232eb +README.zh.md: 7759f6aa16c668f8d7e5b67071d1f29e7007f7a1 diff --git a/packages/skill/skill-badge/README.md b/packages/skill/skill-badge/README.md index fe5c97af8b..a51db5d894 100644 --- a/packages/skill/skill-badge/README.md +++ b/packages/skill/skill-badge/README.md @@ -69,7 +69,7 @@ The provider is an immutable, synchronously registered skill source: it register | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry and the immutable provider: one candidate, resource base, body load | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; the package owns one immutable provider registration, while the skill registry owns registration uniqueness and lifecycle checks. | | [`assets/`](assets/) | Packaged skill body (`dsh-badge.md`) and PNG asset (`dsh-badge.png`) | diff --git a/packages/skill/skill-badge/README.zh.md b/packages/skill/skill-badge/README.zh.md index e489f13306..7759f6aa16 100644 --- a/packages/skill/skill-badge/README.zh.md +++ b/packages/skill/skill-badge/README.zh.md @@ -69,7 +69,7 @@ agent(智能体)可以通过该内置提供方加载官方「powered by dsh | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口与不可变提供方:一个候选项、资源基底、正文加载 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包只持有一个不可变 provider 注册,注册唯一性与生命周期由 skill registry 负责。 | | [`assets/`](assets/) | 随包分发的 skill 正文(`dsh-badge.md`)与 PNG 资源(`dsh-badge.png`) | diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 507cee3436..57a96874a4 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -18,26 +18,19 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/invariant.js", "assets", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/skill/skill-badge/src/invariant.ts b/packages/skill/skill-badge/src/invariant.ts deleted file mode 100644 index 7f36b07a7d..0000000000 --- a/packages/skill/skill-badge/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-skill-badge`. - * @module @deepseek-ai/dsh-skill-badge/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-skill-badge' - -/** Cordis companion plugin name. */ -export const name = 'skill-badge-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package owns one immutable provider registration, - * while the skill registry owns registration uniqueness and lifecycle checks. - */ -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/skill/skill-badge/tsconfig.json b/packages/skill/skill-badge/tsconfig.json index 98dd02c62a..ade98cfa06 100644 --- a/packages/skill/skill-badge/tsconfig.json +++ b/packages/skill/skill-badge/tsconfig.json @@ -8,7 +8,6 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../skill" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../skill" } ] } diff --git a/packages/skill/skill-filesystem/README.i18n.yaml b/packages/skill/skill-filesystem/README.i18n.yaml index c78a788da1..9940f3d006 100644 --- a/packages/skill/skill-filesystem/README.i18n.yaml +++ b/packages/skill/skill-filesystem/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/skill/skill-filesystem/README.md -README.md: 2044ad11bf1ea9b6ec02571cf0107f62cf6ad0a9 -README.zh.md: 5708ed896861677dc829ef08419255d8bce6a204 +README.md: 808df61c0fa248ba02b4e2993e6cbe60ede35f7c +README.zh.md: 6e542dee5bb275488cea9dadba10decd6ecb6733 diff --git a/packages/skill/skill-filesystem/README.md b/packages/skill/skill-filesystem/README.md index 2044ad11bf..808df61c0f 100644 --- a/packages/skill/skill-filesystem/README.md +++ b/packages/skill/skill-filesystem/README.md @@ -101,7 +101,7 @@ The provider is built on two separations. First, catalog versus body: discovery | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry, provider, root resolution, frontmatter parsing, watch manager | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Discovery flow diff --git a/packages/skill/skill-filesystem/README.zh.md b/packages/skill/skill-filesystem/README.zh.md index 5708ed8968..6e542dee5b 100644 --- a/packages/skill/skill-filesystem/README.zh.md +++ b/packages/skill/skill-filesystem/README.zh.md @@ -101,7 +101,7 @@ skill 可以是被扫描根目录顶层的目录 bundle `/SKILL.md`,也 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口、提供方、根解析、frontmatter 解析、监视管理器 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 | ### 发现流程 diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 4821aba5d4..5b7a096b71 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -18,22 +18,16 @@ "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-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -45,7 +39,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/skill/skill-filesystem/src/invariant.ts b/packages/skill/skill-filesystem/src/invariant.ts deleted file mode 100644 index 0b9907b9e1..0000000000 --- a/packages/skill/skill-filesystem/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-skill-filesystem`. - * @module @deepseek-ai/dsh-skill-filesystem/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-skill-filesystem' - -/** Cordis companion plugin name. */ -export const name = 'skill-filesystem-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/skill/skill-filesystem/tsconfig.json b/packages/skill/skill-filesystem/tsconfig.json index 80713dee97..5962881448 100644 --- a/packages/skill/skill-filesystem/tsconfig.json +++ b/packages/skill/skill-filesystem/tsconfig.json @@ -11,7 +11,6 @@ { "path": "../../../vendor/schemastery" }, { "path": "../../fs/fs" }, { "path": "../../util/home-paths" }, - { "path": "../skill" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "../skill" } ] } diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 7bddb16541..14b1674ef6 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/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/skill/skill/README.md -README.md: 662c230d8f366fa6dc2f36cdf21e2b9d51493f8b -README.zh.md: 81737e53b910fbd7dd0ee1e575d719d9f48e6548 +README.md: 004c5a1bf01d16fd03e54463fd06e6cde168811e +README.zh.md: 3d964c4bb7c0f5610152413cfd1de576852aab13 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 662c230d8f..004c5a1bf0 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -86,7 +86,7 @@ The registry is host+per-scope layered, the shape the tools registry established | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry, `SkillRegistry` service, candidate and definition validation, shared model-facing rendering | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; provider/runtime maps and revisioned caches mutate atomically inside the registry, which exposes no independent change event or snapshot for cross-checking them. | ### Catalog collection diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 81737e53b9..3d964c4bb7 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -86,7 +86,7 @@ agent(智能体)和用户可以通过单一查找使用可复用的任务专 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口、`SkillRegistry` 服务、候选项与定义验证、共享的面向模型渲染 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;provider/runtime map 与带 revision 的 cache 在 registry 内原子变更,且没有独立 change event 或 snapshot 可供交叉核对。 | ### 目录收集 diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index ff95cad8d4..64f37fbc48 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -41,7 +35,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts deleted file mode 100644 index b047a4476e..0000000000 --- a/packages/skill/skill/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-skill`. - * @module @deepseek-ai/dsh-skill/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-skill' - -/** Cordis companion plugin name. */ -export const name = 'skill-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: provider/runtime maps and revisioned caches mutate atomically inside the - * registry, which exposes no independent change event or snapshot for cross-checking them. - */ -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/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index 609595cb6c..ba240781d2 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -20,9 +20,6 @@ }, { "path": "../../llm/llm" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index f08f36d793..926c4b00cf 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: c814f2cd78ed53576d1f52b1d47f16826e14e77e -README.zh.md: c083f4d8419df9de3148343be857a4ea1b1522db +README.md: 458c1d1eb8f94496cb93b4c5512502d5aa77849a +README.zh.md: aa4ac4ad773f013b4aa7faea3abdd66230eca2e4 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index c814f2cd78..458c1d1eb8 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -77,7 +77,7 @@ The package is built on two ideas. First, the catalog is a durable projection, d | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, catalog and gesture pre-step listeners, rendering and digest | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. | ### Catalog lifecycle diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c083f4d841..aa4ac4ad77 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -77,7 +77,7 @@ agent(智能体)可以在会话期间发现并加载 skill(技能):在 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、目录与手势 pre-step 监听器、渲染与 digest | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;这个模型侧 adapter 没有独立 lifecycle stream;执行关系由它调用的 capability seam 负责。 | ### 目录生命周期 diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index e2823175d4..0064cd0a51 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -44,7 +38,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts deleted file mode 100644 index 770edf99e5..0000000000 --- a/packages/skill/tool-skill/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-skill`. - * @module @deepseek-ai/dsh-tool-skill/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-tool-skill' - -/** Cordis companion plugin name. */ -export const name = 'tool-skill-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/skill/tool-skill/tsconfig.json b/packages/skill/tool-skill/tsconfig.json index b1f2c8492b..906e36c468 100644 --- a/packages/skill/tool-skill/tsconfig.json +++ b/packages/skill/tool-skill/tsconfig.json @@ -29,9 +29,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml index 1a28aa27a5..e626304222 100644 --- a/packages/spill/spill-local/README.i18n.yaml +++ b/packages/spill/spill-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/spill/spill-local/README.md -README.md: 840e1dff3bf541636354e3a8dc3be619e0dd4cf4 -README.zh.md: 1f4ddf911ea11301c9ccb5520197debb95b02352 +README.md: 836337b0c3077fcd0cf8906e52a127412582b146 +README.zh.md: c827e33b8e9281a9d9e7517ba97432ec7a5b4a0b diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index 840e1dff3b..836337b0c3 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -84,7 +84,7 @@ The backend owns storage details only, on one principle: **a spilled tool result | [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, the `LocalSpillStore` service, cleanup lifecycle, locator and retrieval-hint assembly | | [`src/cleanup.ts`](src/cleanup.ts) | One-shot age sweep, filesystem-identity checks, symlink and ownership safeguards | | [`src/store.ts`](src/store.ts) | Cordis-free storage mechanics: private root, session directory, safe-name encoding, exclusive write | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the seam) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### File naming and write diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md index 1f4ddf911e..c827e33b8e 100644 --- a/packages/spill/spill-local/README.zh.md +++ b/packages/spill/spill-local/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:`Config`、`LocalSpillStore` 服务、清理生命周期、定位信息与取回提示的组装 | | [`src/cleanup.ts`](src/cleanup.ts) | 一次性按年龄扫描、文件系统身份检查、符号链接和所有权保护 | | [`src/store.ts`](src/store.ts) | 与 Cordis 无关的存储机制:私有根目录、会话目录、安全名称编码、排他写入 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在 seam 处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在 seam 处强制执行。 | ### 文件命名与写入 diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index e594a20dce..4009347fe1 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -18,21 +18,15 @@ "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/dsh-spill": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -43,7 +37,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts deleted file mode 100644 index 2651172635..0000000000 --- a/packages/spill/spill-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-spill-local`. - * @module @deepseek-ai/dsh-spill-local/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-spill-local' - -/** Cordis companion plugin name. */ -export const name = 'spill-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/spill/spill-local/tsconfig.json b/packages/spill/spill-local/tsconfig.json index bb3b1d0251..511f24cf1b 100644 --- a/packages/spill/spill-local/tsconfig.json +++ b/packages/spill/spill-local/tsconfig.json @@ -17,9 +17,6 @@ }, { "path": "../spill" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/spill/spill-policy/README.i18n.yaml b/packages/spill/spill-policy/README.i18n.yaml index 213d02d550..4f935ed955 100644 --- a/packages/spill/spill-policy/README.i18n.yaml +++ b/packages/spill/spill-policy/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/spill/spill-policy/README.md -README.md: ee93ef40c1655776ea4ece9f682223a9def185bb -README.zh.md: bd46545f5c16dba50a6c5f6562bcc80d8996b553 +README.md: 4e7d83e81c865952c14af31cf13b7094e7e5f071 +README.zh.md: ef6036478a8d751f9de853df096397e216d4163a diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index ee93ef40c1..4e7d83e81c 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -92,7 +92,7 @@ A `tools/post-execute` waterfall listener (registered with `prepend`, delegating |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` validation, the two waterfall listeners, the shared replacement helper | | [`src/types.ts`](src/types.ts) | `SpillPolicyExec`: the minimal structural view of a tool execution the policy reads for the owning session id | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the seam) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Failure modes diff --git a/packages/spill/spill-policy/README.zh.md b/packages/spill/spill-policy/README.zh.md index bd46545f5c..ef6036478a 100644 --- a/packages/spill/spill-policy/README.zh.md +++ b/packages/spill/spill-policy/README.zh.md @@ -92,7 +92,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` 校验、两个 waterfall 监听器、共享替换辅助函数 | | [`src/types.ts`](src/types.ts) | `SpillPolicyExec`:策略读取所属会话 id 所需的最小结构化工具执行视图 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在 seam 处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在 seam 处强制执行。 | ### 故障模式 diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 44824ae2ab..b692f20d48 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -46,7 +40,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts deleted file mode 100644 index 860b4c5187..0000000000 --- a/packages/spill/spill-policy/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-spill-policy`. - * @module @deepseek-ai/dsh-spill-policy/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-spill-policy' - -/** Cordis companion plugin name. */ -export const name = 'spill-policy-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/spill/spill-policy/tsconfig.json b/packages/spill/spill-policy/tsconfig.json index 45e3d4a177..cbe56468bd 100644 --- a/packages/spill/spill-policy/tsconfig.json +++ b/packages/spill/spill-policy/tsconfig.json @@ -29,9 +29,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/spill/spill/README.i18n.yaml b/packages/spill/spill/README.i18n.yaml index 0a51f49867..4696f02d67 100644 --- a/packages/spill/spill/README.i18n.yaml +++ b/packages/spill/spill/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/spill/spill/README.md -README.md: 735c628f3aef6fa4b76d45a69375a2a2a8b72898 -README.zh.md: 1953368bb9010f64d76b2691a45e82780e150ea7 +README.md: 260407b49681183569416d5a9b820a7014479107 +README.zh.md: 85bf20fc0a50dd3f224c67e245ed3b4dfb692c4f diff --git a/packages/spill/spill/README.md b/packages/spill/spill/README.md index 735c628f3a..260407b496 100644 --- a/packages/spill/spill/README.md +++ b/packages/spill/spill/README.md @@ -89,7 +89,7 @@ The package is built on one separation and a deliberate minimum: |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `SpillStore` service and its `saveText` contract | | [`src/types.ts`](src/types.ts) | Vocabulary: `SaveTextSpill`, `SpillRef`, branded `SpillLocator`, `SpillOwner`, `SpillSource` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the seam) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Data model diff --git a/packages/spill/spill/README.zh.md b/packages/spill/spill/README.zh.md index 1953368bb9..85bf20fc0a 100644 --- a/packages/spill/spill/README.zh.md +++ b/packages/spill/spill/README.zh.md @@ -89,7 +89,7 @@ const ref = await ctx.spillStore.saveText({ |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SpillStore` 服务及其 `saveText` 约定 | | [`src/types.ts`](src/types.ts) | 词汇:`SaveTextSpill`、`SpillRef`、带品牌类型 `SpillLocator`、`SpillOwner`、`SpillSource` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在 seam 处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在 seam 处强制执行。 | ### 数据模型 diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index ea192a48e0..f6b9d51cdb 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -18,29 +18,22 @@ "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-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts deleted file mode 100644 index 34e39d2e1d..0000000000 --- a/packages/spill/spill/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-spill`. - * @module @deepseek-ai/dsh-spill/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-spill' - -/** Cordis companion plugin name. */ -export const name = 'spill-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/spill/spill/tsconfig.json b/packages/spill/spill/tsconfig.json index 8b04eab279..37663ce0b3 100644 --- a/packages/spill/spill/tsconfig.json +++ b/packages/spill/spill/tsconfig.json @@ -20,9 +20,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 000e479d7f..ed3d4b14a3 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/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/storage/storage-json/README.md -README.md: 76df80de70cb58c78ff4339b3f1cbd137ca49ef0 -README.zh.md: 7cbf63d14e3c257ef7670f559792129dea6ff497 +README.md: 383452b039f6190a523b8dcf969693e5e08a9456 +README.zh.md: 06cf35804abfea4c221fe966dc4d395d0ad5a707 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 76df80de70..383452b039 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -96,7 +96,7 @@ A `per-record` table document at `///.json` has the form | [`src/per-record-unit.ts`](src/per-record-unit.ts) | One `per-record` unit: tree reads, path-safe records, and one-document writes | | [`src/format.ts`](src/format.ts) | Whole-unit and record serialization with version validation | | [`src/atomic.ts`](src/atomic.ts) | Atomic file replacement: temp write, fsync, rename, directory fsync | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant: correctness is round-trip durability) | +| — | No runtime invariant companion is published; correctness here is write-durability and publish-then-reparse equivalence, which require medium round-trip tests (the shared backend conformance suite); the backend exposes no continuously observable in-process relation. | diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index 7cbf63d14e..06cf35804a 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -96,7 +96,7 @@ kind: "package-reference" | [`src/per-record-unit.ts`](src/per-record-unit.ts) | 一个 `per-record` 单元:目录树读取、路径安全记录与单文档写入 | | [`src/format.ts`](src/format.ts) | 带版本校验的整单元与记录序列化 | | [`src/atomic.ts`](src/atomic.ts) | 原子文件替换:临时文件写入、fsync、rename、目录 fsync | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式:正确性靠往返持久性) | +| — | 不发布运行时不变式伴生入口;正确性靠往返持久性。 | diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 0e1c060e6a..288101ae53 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -18,21 +18,15 @@ "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/dsh-storage": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -40,7 +34,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/storage/storage-json/src/invariant.ts b/packages/storage/storage-json/src/invariant.ts deleted file mode 100644 index 915a898e8f..0000000000 --- a/packages/storage/storage-json/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-storage-json`. - * @module @deepseek-ai/dsh-storage-json/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-storage-json' - -/** Cordis companion plugin name. */ -export const name = 'storage-json-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: correctness here is write-durability and - * publish-then-reparse equivalence, which require medium round-trip tests - * (the shared backend conformance suite); the backend exposes no continuously - * observable in-process relation. - */ -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/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 41ad071ea0..bafbf92ef4 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -4,10 +4,8 @@ import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { runKvBackendContract } from '../../storage/tests/contract.ts' import { Config, JsonStorageBackend, apply } from '../src/index.ts' -import * as InvariantCompanion from '../src/invariant.ts' const roots: string[] = [] @@ -198,15 +196,6 @@ describe('json backend specifics', () => { await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' }) }) - it('registers the invariant companion and disposes cleanly', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry) - const fiber = await ctx.plugin(InvariantCompanion) - // Disposal releases the reservation: a fresh mount succeeds. - await fiber.dispose() - await ctx.plugin(InvariantCompanion) - }) - it('close drains in-flight writes and blocks in-flight opens', async () => { const root = await freshRoot() const backend = new JsonStorageBackend(root) diff --git a/packages/storage/storage-json/tsconfig.json b/packages/storage/storage-json/tsconfig.json index c0bae3e504..a7bea73bd4 100644 --- a/packages/storage/storage-json/tsconfig.json +++ b/packages/storage/storage-json/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../storage" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index 45079dcc26..741e14685f 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/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/storage/storage-sqlite/README.md -README.md: 4abb767a0dc593c804e33c2822ad07ab2b93360b -README.zh.md: 087c99fc85ce66b9d92f6ecc602698365614d106 +README.md: 9fb12975d4769cff2f9c43f14b77524eec75e398 +README.zh.md: de19b1e71e3903556a1f72ef43901546b83e3664 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index 4abb767a0d..9fb12975d4 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -84,7 +84,7 @@ Opening the database mirrors the session-persistence SQLite backend: `mkdir` the | [`src/index.ts`](src/index.ts) | Plugin entry: backend registration, `path`/`journalMode` config, unit table | | [`src/schema.ts`](src/schema.ts) | Open sequence, physical layout version, metadata tables, record table naming | | [`src/unit.ts`](src/unit.ts) | One opened unit: prepared statements, JSON value parse, close | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant: versions are open-time checks) | +| — | No runtime invariant companion is published; schema-version and unit-version consistency are open-time checks that reject before a unit exists, and durability needs the backend round-trip tests in the shared KV conformance suite; this package exposes no continuously observable in-process relation. | diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 087c99fc85..de19b1e71e 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:后端注册、`path`/`journalMode` 配置、单元表 | | [`src/schema.ts`](src/schema.ts) | 打开顺序、物理布局版本、元数据表、记录表命名 | | [`src/unit.ts`](src/unit.ts) | 一个已打开单元:预处理语句、JSON 值解析、关闭 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式:版本是打开时检查) | +| — | 不发布运行时不变式伴生入口;版本是打开时检查。 | diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 863c87caec..3a4e8fe645 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -18,21 +18,15 @@ "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/dsh-storage": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -40,7 +34,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/storage/storage-sqlite/src/invariant.ts b/packages/storage/storage-sqlite/src/invariant.ts deleted file mode 100644 index 1775bd9524..0000000000 --- a/packages/storage/storage-sqlite/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-storage-sqlite`. - * @module @deepseek-ai/dsh-storage-sqlite/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-storage-sqlite' - -/** Cordis companion plugin name. */ -export const name = 'storage-sqlite-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: schema-version and unit-version consistency are - * open-time checks that reject before a unit exists, and durability needs the - * backend round-trip tests in the shared KV conformance suite; this package - * exposes no continuously observable in-process relation. - */ -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/storage/storage-sqlite/tests/invariant.spec.ts b/packages/storage/storage-sqlite/tests/invariant.spec.ts deleted file mode 100644 index 62692c361c..0000000000 --- a/packages/storage/storage-sqlite/tests/invariant.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as StorageSqliteInvariant from '../src/invariant.ts' - -describe('invariant companion', () => { - it('registers under the package name with an explained-empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(StorageSqliteInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/storage/storage-sqlite/tsconfig.json b/packages/storage/storage-sqlite/tsconfig.json index c0bae3e504..a7bea73bd4 100644 --- a/packages/storage/storage-sqlite/tsconfig.json +++ b/packages/storage/storage-sqlite/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../storage" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/storage/storage/README.i18n.yaml b/packages/storage/storage/README.i18n.yaml index 4c674cd72b..4f487fe554 100644 --- a/packages/storage/storage/README.i18n.yaml +++ b/packages/storage/storage/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/storage/storage/README.md -README.md: d81dbe8f99ae7594b8f33a389deec77259bbd610 -README.zh.md: 9d1078782e3e09d3f9b8925e87c9b2f40085daca +README.md: 27034b7d3fa67216e777d31c54ceb96dc6c16338 +README.zh.md: 595070f2f2e5f7628063cfa442ea924becf3b4e8 diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md index d81dbe8f99..27034b7d3f 100644 --- a/packages/storage/storage/README.md +++ b/packages/storage/storage/README.md @@ -86,7 +86,7 @@ The hub is a pure registration table with two faces, designed so backends and da | [`src/registry.ts`](src/registry.ts) | `BackendRegistry`: name → backend table, registration disposers | | [`src/backend.ts`](src/backend.ts) | The backend contract: facets, units, `UNIT_NAME_RE` | | [`src/error.ts`](src/error.ts) | `StorageError` codes shared by the hub and every backend | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant: a pure registration table) | +| — | No runtime invariant companion is published; the hub is a pure registration table (names → backends, forms → facilities) whose consistency is fully enforced at the call sites (duplicate/missing entries fail loud synchronously); it owns no event stream or mutable medium to cross-check. | | [`tests/contract.ts`](tests/contract.ts) | The shared conformance suite run against each backend | diff --git a/packages/storage/storage/README.zh.md b/packages/storage/storage/README.zh.md index 9d1078782e..595070f2f2 100644 --- a/packages/storage/storage/README.zh.md +++ b/packages/storage/storage/README.zh.md @@ -86,7 +86,7 @@ kind: "package-reference" | [`src/registry.ts`](src/registry.ts) | `BackendRegistry`:名称 → 后端表、注册资源释放函数 | | [`src/backend.ts`](src/backend.ts) | 后端约定:分面、单元、`UNIT_NAME_RE` | | [`src/error.ts`](src/error.ts) | 枢纽与每个后端共享的 `StorageError` 代码 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式:纯注册表) | +| — | 不发布运行时不变式伴生入口;纯注册表。 | | [`tests/contract.ts`](tests/contract.ts) | 针对每个后端运行的共享一致性套件 | diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 7d92719411..69dbf7d2e6 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage/src/invariant.ts b/packages/storage/storage/src/invariant.ts deleted file mode 100644 index 1c303c43fa..0000000000 --- a/packages/storage/storage/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-storage`. - * @module @deepseek-ai/dsh-storage/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-storage' - -/** Cordis companion plugin name. */ -export const name = 'storage-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the hub is a pure registration table (names → - * backends, forms → facilities) whose consistency is fully enforced at the - * call sites (duplicate/missing entries fail loud synchronously); it owns no - * event stream or mutable medium to cross-check. - */ -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/storage/storage/tsconfig.json b/packages/storage/storage/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/storage/storage/tsconfig.json +++ b/packages/storage/storage/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index b84e755713..7237db63df 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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/subagent/subagent-acp/README.md -README.md: 185a06a4003386a18414998e9cbaa314b9599921 -README.zh.md: 1594677f2ecdacd4ca389337fb29bd01ff18a108 +README.md: 2d929738de2f761cbc597fae6cb52f7d44d83536 +README.zh.md: d67e174a44497148d08ef54d13f17df934db2018 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 185a06a400..2d929738de 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -175,3 +175,5 @@ This Dev Note is working context for maintainers: open questions and undecided d - **Continuable ACP children** — would require persisting the remote session id and a per-child continuation advertisement. + +**Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 1594677f2e..d67e174a44 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -175,3 +175,5 @@ spawn、初始化或新建会话失败会在发布前拒绝,通常先等待子 - **可继续 ACP 子级**——需要持久化远程会话 id 与逐子级的继续执行能力声明。 + +**运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 5a374ea511..00d313b004 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -50,7 +44,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts deleted file mode 100644 index 536404e6d9..0000000000 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-acp`. - * @module @deepseek-ai/dsh-subagent-acp/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-subagent-acp' - -/** Cordis companion plugin name. */ -export const name = 'subagent-acp-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index cdc8731e5f..246db5b10f 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../test-support/loader-smoke" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index cc264ed624..2ed36236ee 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 9fcce15b45a77a7a3b7dd4e6299609f8ef5ee484 -README.zh.md: 5066c2553f7bd7f71fd87c7ee5acb800b7398b59 +README.md: 7ec86fc2092f22fc82665d9660edb42a01a3d99c +README.zh.md: 2ef2e957b19f56b664f79e429b067a59557373f8 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 9fcce15b45..7ec86fc209 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -193,3 +193,5 @@ This Dev Note is working context for maintainers: open questions and undecided d - **Version-pinned protocol** — the runtime dependency is pinned to Agent SDK 0.3.241; upgrading pins a new SDK version and requires re-running the keyless real-product and loader-composition evidence. + +**Runtime invariant:** No companion is published. Lifecycle pairing belongs to the shared subagent service and process-tree ownership belongs to the subprocess service. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 5066c2553f..2ef2e957b1 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -193,3 +193,5 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **版本锁定的协议**——运行时依赖锁定为 Agent SDK 0.3.241;升级会锁定新的 SDK 版本,并需要重新运行无密钥真实产品与 loader 组合证据。 + +**运行时不变式:** 不发布伴生入口。生命周期配对属于共享 subagent service,process-tree 所有权属于 subprocess service。 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index c16cc1045a..140a84148b 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -18,16 +18,11 @@ "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", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -38,7 +33,6 @@ } }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -57,7 +51,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts deleted file mode 100644 index 44fa400e16..0000000000 --- a/packages/subagent/subagent-claude-code/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for - * `@deepseek-ai/dsh-subagent-claude-code`. - * @module @deepseek-ai/dsh-subagent-claude-code/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-subagent-claude-code' - -/** Cordis companion plugin name. */ -export const name = 'subagent-claude-code-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and process-tree ownership belongs to the subprocess service. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - plugin context carrying the invariant registry. - * @returns the installed registration's disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index f9860ac812..04e1eadcb8 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -23,7 +23,6 @@ import { vi, } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -35,7 +34,6 @@ import type { import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as claudeCode from '../src/index.ts' -import * as invariant from '../src/invariant.ts' import { claudeSpawnSpec, ManagedClaudeCodeProcess, @@ -718,28 +716,12 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) - it('keeps the Loader namespace shape and package-owned empty invariant', async () => { + it('keeps the Loader namespace shape', () => { expect('default' in claudeCode).toBe(false) expect(claudeCode.name).toBe('subagent-claude-code') expect(claudeCode.inject).toEqual(['subagents', 'subprocess']) const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(claudeCode)).toBe(claudeCode) - - const dispose = vi.fn() - const register = vi.fn(( - _packageName: string, - _installer: InvariantInstaller, - ) => dispose) - const ctx = { invariants: { register } } as unknown as Context - await expect(invariant.apply(ctx)).resolves.toBe(dispose) - expect(register).toHaveBeenCalledWith( - '@deepseek-ai/dsh-subagent-claude-code', - expect.any(Function), - ) - const install = register.mock.calls[0]![1] - await install(new Context(), (message) => { throw new Error(message) }) - expect(invariant.name).toBe('subagent-claude-code-invariant') - expect(invariant.inject).toEqual(['invariants']) }) }) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json index c887e844e8..cbb1b66226 100644 --- a/packages/subagent/subagent-claude-code/tsconfig.json +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -29,9 +29,6 @@ }, { "path": "../../util/timeout" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da4e19f820..5a113e66ef 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent/subagent-codex/README.md -README.md: db1250987333f6431f9a05f5e440e9d47a1d36f2 -README.zh.md: 4142c3ad54abc19ff3bd86a1b7fb6708eadfe9bf +README.md: 399f01bd057c3278356a17c04137be6596274866 +README.zh.md: f2ac1287cc0678822e655b6d06f0b414ebee2b81 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index db12509873..399f01bd05 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -191,3 +191,5 @@ This Dev Note is working context for maintainers: open questions and undecided d - **Version-pinned protocol** — the runtime dependency is pinned to `@openai/codex@0.149.1`; upgrading requires regenerating the upstream schema evidence and rerunning the credentialed nonce tests. + +**Runtime invariant:** No companion is published. Lifecycle pairing belongs to the shared subagent service and process-tree ownership belongs to the subprocess service. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 4142c3ad54..f2ac1287cc 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -191,3 +191,5 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 - **版本锁定的协议**——运行时依赖锁定为 `@openai/codex@0.149.1`;升级需要重新生成上游 schema 证据并重新运行带凭证的随机数测试。 + +**运行时不变式:** 不发布伴生入口。生命周期配对属于共享 subagent service,process-tree 所有权属于 subprocess service。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 1843172caf..0b86a01ca0 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -18,16 +18,11 @@ "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", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -38,7 +33,6 @@ } }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -56,7 +50,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts deleted file mode 100644 index ec9a6302c4..0000000000 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`. - * @module @deepseek-ai/dsh-subagent-codex/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-subagent-codex' - -/** Cordis companion plugin name. */ -export const name = 'subagent-codex-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and process-tree ownership belongs to the subprocess service. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - plugin context carrying the invariant registry. - * @returns the installed registration's disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index be1136e56a..a203c42c82 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -7,7 +7,6 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import * as yaml from 'js-yaml' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -19,7 +18,6 @@ import type { } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' -import * as invariant from '../src/invariant.ts' import { CODEX_PERMISSION_MODES, DEFAULT_CODEX_PERMISSION_MODE, @@ -714,28 +712,12 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) - it('keeps the namespace export shape and package-owned empty invariant', async () => { + it('keeps the namespace export shape', () => { expect('default' in codex).toBe(false) expect(codex.name).toBe('subagent-codex') expect(codex.inject).toEqual(['subagents', 'subprocess']) const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(codex)).toBe(codex) - - const dispose = vi.fn() - const register = vi.fn(( - _packageName: string, - _installer: InvariantInstaller, - ) => dispose) - const ctx = { invariants: { register } } as unknown as Context - await expect(invariant.apply(ctx)).resolves.toBe(dispose) - expect(register).toHaveBeenCalledWith( - '@deepseek-ai/dsh-subagent-codex', - expect.any(Function), - ) - const install = register.mock.calls[0]![1] - await install(new Context(), (message) => { throw new Error(message) }) - expect(invariant.name).toBe('subagent-codex-invariant') - expect(invariant.inject).toEqual(['invariants']) }) }) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json index 607e342dbb..a886719201 100644 --- a/packages/subagent/subagent-codex/tsconfig.json +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../util/timeout" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 063ba755f7..87d9789773 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/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/subagent/subagent-dsh-sdk/README.md -README.md: 59dc34f33a924c7cc67225a7337df2f8a3935f30 -README.zh.md: 31da5276333f5ea6a90bd283639ceb495345075e +README.md: 7189daa8c47cf260dab55571466350381b160ebd +README.zh.md: d82185194c53c853c3526abb785ca0608004d89b diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 59dc34f33a..7189daa8c4 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -186,3 +186,5 @@ This Dev Note is working context for maintainers: open questions and undecided d - **Remote runtimes** — a remote runtime would need its own backend and workspace mapping. + +**Runtime invariant:** No companion is published. Run lifecycle pairing is owned and checked by the subagent seam's invariant; this backend's own state lives in the child process beyond this context's event streams. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index 31da527633..d82185194c 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -186,3 +186,5 @@ kind: "package-reference" - **远程运行时**——远程运行时需要独立的后端与工作区映射。 + +**运行时不变式:** 不发布伴生入口。run 生命周期配对由 subagent seam 的不变式检查;backend 自身状态位于子进程中,超出当前 Context 的事件流。 diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 1e8c355d4d..d542f270f8 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sdk-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -50,7 +44,6 @@ "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", diff --git a/packages/subagent/subagent-dsh-sdk/src/invariant.ts b/packages/subagent/subagent-dsh-sdk/src/invariant.ts deleted file mode 100644 index cddaed01bf..0000000000 --- a/packages/subagent/subagent-dsh-sdk/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-dsh-sdk`. - * @module @deepseek-ai/dsh-subagent-dsh-sdk/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-subagent-dsh-sdk' - -/** Cordis companion plugin name. */ -export const name = 'subagent-dsh-sdk-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: run lifecycle pairing is owned and checked by the - * subagent seam's invariant; this backend's own state lives in the child - * process beyond this context's event streams. - */ -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/subagent/subagent-dsh-sdk/tsconfig.json b/packages/subagent/subagent-dsh-sdk/tsconfig.json index 1b6c41e384..261db28f02 100644 --- a/packages/subagent/subagent-dsh-sdk/tsconfig.json +++ b/packages/subagent/subagent-dsh-sdk/tsconfig.json @@ -40,9 +40,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index 974678debb..902a15ae5b 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-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/subagent/subagent-fork-in-process/README.md -README.md: 408d5efbd0426297eb02c44babf91d7fd2a271ff -README.zh.md: d1e0c2e034556d8dde5bff12bb09c4ec0ae0d678 +README.md: dd5fba5fce1f1883912a4ec4ef27a66140743280 +README.zh.md: 84eabe803d7ddbd464d83e04eb502382ed0c1bbf diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index 408d5efbd0..dd5fba5fce 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -76,7 +76,7 @@ One difference from spawn, expressed as data: the backend computes the balanced | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Provider registration: prefix computation, `Config` schema, capability declaration | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Run flow diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index d1e0c2e034..84eabe803d 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -76,7 +76,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 提供方注册:前缀计算、`Config` schema、能力声明 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 | ### 运行流程 diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 7cb3a003d3..1f6623c3e8 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", diff --git a/packages/subagent/subagent-fork-in-process/src/invariant.ts b/packages/subagent/subagent-fork-in-process/src/invariant.ts deleted file mode 100644 index 438f2be64e..0000000000 --- a/packages/subagent/subagent-fork-in-process/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-fork-in-process`. - * @module @deepseek-ai/dsh-subagent-fork-in-process/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-subagent-fork-in-process' - -/** Cordis companion plugin name. */ -export const name = 'subagent-fork-in-process-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/subagent/subagent-fork-in-process/tsconfig.json b/packages/subagent/subagent-fork-in-process/tsconfig.json index cc90c4fa3d..892c4377ef 100644 --- a/packages/subagent/subagent-fork-in-process/tsconfig.json +++ b/packages/subagent/subagent-fork-in-process/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../subagent-in-process-driver" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 8009b8b8d8..0a7c5e20f3 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/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/subagent/subagent-in-process-driver/README.md -README.md: 4f343862d336a0cf697ab3bf5cf73f86d1d235fe -README.zh.md: a77032317c592f4431b9bba4f5e808f4da8eb702 +README.md: 7c8e0b896637d6b5afd1de628db56d1f5224486d +README.zh.md: f37cfe3e3a15697f4286a40ab203fa8ac87746f0 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md index 4f343862d3..7c8e0b8966 100644 --- a/packages/subagent/subagent-in-process-driver/README.md +++ b/packages/subagent/subagent-in-process-driver/README.md @@ -73,7 +73,7 @@ The required request signal covers both startup and the live run. Before publica |---|---| | [`src/index.ts`](src/index.ts) | Run driver: creation, one-turn drive, result reading, disposal | | [`src/structured.ts`](src/structured.ts) | Structured-output runtime: capture tool, prompt section, guard, commit | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index a77032317c..f37cfe3e3a 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -73,7 +73,7 @@ kind: "package-library" |---|---| | [`src/index.ts`](src/index.ts) | 运行驱动器:创建、单轮驱动、结果读取、dispose | | [`src/structured.ts`](src/structured.ts) | 结构化输出运行时:捕获工具、提示词段、防护、提交 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 | diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 5b74fbd948..b8cdf9a197 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/subagent/subagent-in-process-driver/src/invariant.ts b/packages/subagent/subagent-in-process-driver/src/invariant.ts deleted file mode 100644 index 761c29ab77..0000000000 --- a/packages/subagent/subagent-in-process-driver/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-in-process-driver`. - * @module @deepseek-ai/dsh-subagent-in-process-driver/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-subagent-in-process-driver' - -/** Cordis companion plugin name. */ -export const name = 'subagent-in-process-driver-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/subagent/subagent-in-process-driver/tsconfig.json b/packages/subagent/subagent-in-process-driver/tsconfig.json index b274298f98..7b7a015cc9 100644 --- a/packages/subagent/subagent-in-process-driver/tsconfig.json +++ b/packages/subagent/subagent-in-process-driver/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index c701d9180f..836e74aa99 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-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/subagent/subagent-spawn-in-process/README.md -README.md: 5eb2db403f3c572a6cd708a2bf32c7e9d54aafca -README.zh.md: 028815190ed7880c310661a3b8b7cd63f487afef +README.md: ce3909934efa1b2f5cc57ff046b892e3ec129444 +README.zh.md: c50ab11cca89399ff40d16c6aa23aae456bed1d6 diff --git a/packages/subagent/subagent-spawn-in-process/README.md b/packages/subagent/subagent-spawn-in-process/README.md index 5eb2db403f..ce3909934e 100644 --- a/packages/subagent/subagent-spawn-in-process/README.md +++ b/packages/subagent/subagent-spawn-in-process/README.md @@ -72,7 +72,7 @@ One separation: this backend contributes only the provider registration and the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Provider registration: `Config` schema, capability declaration, `start()` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Run flow diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index 028815190e..c50ab11cca 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 提供方注册:`Config` schema、能力声明、`start()` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 | ### 运行流程 diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 7f523e16f5..b916d7fe17 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -18,21 +18,15 @@ "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/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subagent/subagent-spawn-in-process/src/invariant.ts b/packages/subagent/subagent-spawn-in-process/src/invariant.ts deleted file mode 100644 index 1a75ef9b04..0000000000 --- a/packages/subagent/subagent-spawn-in-process/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-spawn-in-process`. - * @module @deepseek-ai/dsh-subagent-spawn-in-process/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-subagent-spawn-in-process' - -/** Cordis companion plugin name. */ -export const name = 'subagent-spawn-in-process-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/subagent/subagent-spawn-in-process/tsconfig.json b/packages/subagent/subagent-spawn-in-process/tsconfig.json index f6fb07e566..ed3c2cd6d9 100644 --- a/packages/subagent/subagent-spawn-in-process/tsconfig.json +++ b/packages/subagent/subagent-spawn-in-process/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../subagent-in-process-driver" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index 15618d9346..f8b633a201 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/subagent/tool-subagent-control/README.md -README.md: 18cdd9d22be3ce30057bcaa518f3795fcc89f48d -README.zh.md: 46f31d7dcba3d4e05ddf212dda087fd3a671b69e +README.md: c2446f45b0647a01df1a610be41e90fc6a6721f6 +README.zh.md: 1ba0379aeb5602770e77b804d2166a58f9b89f4f diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 18cdd9d22b..c2446f45b0 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -84,7 +84,7 @@ The tool forwards its execution signal, which owns admission only until inbox ac |---|---| | [`src/index.ts`](src/index.ts) | `send_message` and `interrupt_agent` registration | | [`src/list-agents.ts`](src/list-agents.ts) | `list_agents` registration: scopes, status refinement, projection | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; delivery and activation relations are owned by the subagent service it calls. | diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 46f31d7dcb..1ba0379aeb 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | `send_message` 与 `interrupt_agent` 注册 | | [`src/list-agents.ts`](src/list-agents.ts) | `list_agents` 注册:作用域、状态细化、投影 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;这个模型侧 adapter 没有独立 lifecycle stream;delivery 与 activation 关系由 subagent service 负责。 | diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index b669d611bc..7dd8720495 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./list-agents": { "types": "./lib/types/list-agents.d.ts", "default": "./lib/types/list-agents.js" @@ -31,13 +27,11 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -48,7 +42,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts deleted file mode 100644 index 2538fa5065..0000000000 --- a/packages/subagent/tool-subagent-control/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-control`. - * @module @deepseek-ai/dsh-tool-subagent-control/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-tool-subagent-control' - -/** Cordis companion plugin name. */ -export const name = 'tool-subagent-control-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery - * and activation relations are owned by the subagent service it calls. - */ -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/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json index f7d18b7529..e95a276e60 100644 --- a/packages/subagent/tool-subagent-control/tsconfig.json +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../subagent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 8ebd09d64c..df836b7be7 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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/subagent/tool-subagent-report/README.md -README.md: 24dac02c11925de9d99264a7ff672f556701faf2 -README.zh.md: 089919d96677528946a000453ff9abdad174e631 +README.md: 22d63fac4a18bbcf9e84b246c1e39ae2206c9f72 +README.zh.md: 9482452866fa07bacc878413bfe5f6fc3c6b6d88 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index 24dac02c11..22d63fac4a 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -86,7 +86,7 @@ The package registers a continuable-child setup contribution rather than a globa | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Continuable-child setup: `installReportTool`, `Config`, delivery resolution | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; this adapter has no independent lifecycle stream; sender authorization and delivery relations belong to the subagent service. | diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 089919d966..9482452866 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -86,7 +86,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 可继续子级设置:`installReportTool`、`Config`、投递解析 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;这个 adapter 没有独立 lifecycle stream;sender authorization 与 delivery 关系属于 subagent service。 | diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 6428f1c022..70cc96fdfb 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -46,7 +40,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/subagent/tool-subagent-report/src/invariant.ts b/packages/subagent/tool-subagent-report/src/invariant.ts deleted file mode 100644 index 93777a70ec..0000000000 --- a/packages/subagent/tool-subagent-report/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-report`. - * @module @deepseek-ai/dsh-tool-subagent-report/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-tool-subagent-report' - -/** Cordis companion plugin name. */ -export const name = 'tool-subagent-report-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this adapter has no independent lifecycle stream; - * sender authorization and delivery relations belong to the subagent service. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - context carrying the invariant service. - * @returns the registration 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/subagent/tool-subagent-report/tsconfig.json b/packages/subagent/tool-subagent-report/tsconfig.json index 35604b6a8b..f5bde7ed92 100644 --- a/packages/subagent/tool-subagent-report/tsconfig.json +++ b/packages/subagent/tool-subagent-report/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../llm/llm" }, @@ -25,9 +28,6 @@ }, { "path": "../subagent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index f8f2fd603c..76150e77d5 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: 5e609bf96002c62d18684517bcd815e6f4fc0bb0 -README.zh.md: 9e53c9f9444c0c19ed4ec337357a9151d8f90328 +README.md: dd9edbc99578f5411fad993cf88f93429dc9cab2 +README.zh.md: 7bb43f7bbce5ef6b128edc9ef2104d35a5887845 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 5e609bf960..dd9edbc995 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -79,7 +79,7 @@ The provider treats the process tree as the unit of lifetime. POSIX children spa | [`src/terminal.ts`](src/terminal.ts) | `node-pty` terminal handle: foreground inspection, session cleanup, Windows teardown | | [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX process-tree and session inspection | | [`src/windows-inspector.ts`](src/windows-inspector.ts) | Windows Toolhelp32 process-table inspection via koffi | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam owns the contract) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Main flow diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 9e53c9f944..7bb43f7bbc 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -79,7 +79,7 @@ kind: "package-reference" | [`src/terminal.ts`](src/terminal.ts) | `node-pty` 终端句柄:前台检查、会话清理、Windows 拆卸 | | [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX 进程树与会话检查 | | [`src/windows-inspector.ts`](src/windows-inspector.ts) | 经 koffi 的 Windows Toolhelp32 进程表检查 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定归 seam 所有) | +| — | 不发布运行时不变式伴生入口;约定归 seam 所有。 | ### 主流程 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 0db6d18262..bb1fd9b0de 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -18,16 +18,11 @@ "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", "scripts/ensure-spawn-helper.mjs", "lib/types/**/*.d.ts" ], @@ -36,7 +31,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -46,7 +40,6 @@ "node-pty": "1.2.0-beta.15" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/subprocess/subprocess-local/src/invariant.ts b/packages/subprocess/subprocess-local/src/invariant.ts deleted file mode 100644 index 335533d854..0000000000 --- a/packages/subprocess/subprocess-local/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-local`. - * @module @deepseek-ai/dsh-subprocess-local/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-subprocess-local' - -/** Cordis companion plugin name. */ -export const name = 'subprocess-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 322e836024..48c2093a6e 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../util/timeout" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 43ee7d65ea..4c387619f1 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/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/subprocess/README.md -README.md: b25612ceea8e6d21a8836373643a0cf8fad71b18 -README.zh.md: 8ffb7f9c3ff391915c48b204ec9c30f1272a0960 +README.md: 1a8b5afb34f1409368c37f4958cf69a1d111c7ab +README.zh.md: ce2aa4b83af4c04a03e5589ae1cc477112c59e23 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index b25612ceea..1a8b5afb34 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -96,7 +96,7 @@ The seam is built on one separation: the service owns process coordinates and li |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `SubprocessRuntime`, `ctx.subprocess` registration, the shared `scrubbedParentEnv` scrub | | [`src/types.ts`](src/types.ts) | Vocabulary: spawn spec, stdio modes, handles, readers, outcomes, `DSH_*` namespace | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; providers own observations) | +| — | No runtime invariant companion is published; this stateless Service Definition owns spawn-spec/handle types, while Service Providers own observations. | ### Data model and flow diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 8ffb7f9c3f..ce2aa4b83a 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -96,7 +96,7 @@ const output = handle.collected.stdout?.readFrom(0) |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SubprocessRuntime`、`ctx.subprocess` 注册、共享的 `scrubbedParentEnv` 清除 | | [`src/types.ts`](src/types.ts) | 词汇:spawn spec、stdio 模式、句柄、读取器、结果、`DSH_*` 命名空间 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;观察由提供方负责) | +| — | 不发布运行时不变式伴生入口;观察由提供方负责。 | ### 数据模型与流程 diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 1a10def591..fec522383e 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess/src/invariant.ts b/packages/subprocess/subprocess/src/invariant.ts deleted file mode 100644 index 0346915f27..0000000000 --- a/packages/subprocess/subprocess/src/invariant.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess' - -/** Cordis companion plugin name. */ -export const name = 'subprocess-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: this stateless Service Definition owns spawn-spec/handle types, while Service Providers own observations. */ -const install: InvariantInstaller = () => {} - -/** - * Register the subprocess 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)) diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 506300f66d..d9e4d4b2ab 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: c2859996c5b5b3a82a5f78e12628613089eab3e6 -README.zh.md: 738d27f2671647921c80007f2cf7a7d974f211ce +README.md: c9652b542dc7d08851508619d663e750c115f3ec +README.zh.md: 6eb0b2e26092fc593c8d3d5088110a935bd8c0be diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index c2859996c5..c9652b542d 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -83,3 +83,5 @@ The package contributes no stable request prefix, so it does not invalidate mode None. + +**Runtime invariant:** No companion is published. Operations own only call-local native handles. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 738d27f267..6eb0b2e260 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -83,3 +83,5 @@ Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载 无。 + +**运行时不变式:** 不发布伴生入口。操作只持有调用内的 native handle。 diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index cf4202c432..c4cb2feaa5 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -18,28 +18,21 @@ "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/invariant.ts b/packages/subprocess/win32-process/src/invariant.ts deleted file mode 100644 index bd29124923..0000000000 --- a/packages/subprocess/win32-process/src/invariant.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** 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/tests/invariant.spec.ts b/packages/subprocess/win32-process/tests/invariant.spec.ts deleted file mode 100644 index 82078672d3..0000000000 --- a/packages/subprocess/win32-process/tests/invariant.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index 730993dc97..d6035c59a9 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -8,9 +8,6 @@ "references": [ { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 3a79ec2884..33501414cd 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: 779e7802f0e7e018d19a9757707e29601926d474 -README.zh.md: 91c7af9fb62da79e09b1baa9ce7447b91d09448e +README.md: 726373a1e75775ad9bd7bf1f5b97074af39c301d +README.zh.md: e5a1549b5cb307a75664266df371b304d6763269 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 779e7802f0..726373a1e7 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -177,3 +177,5 @@ These limits define where the backend is a poor fit or needs special operational None. + +**Runtime invariant:** No companion is published. Readiness, terminal buffers, and process-tree state are private per-session implementation state, and the backend publishes no independent lifecycle stream or snapshot. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 91c7af9fb6..e5a1549b5c 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -177,3 +177,5 @@ shell 在整个生命周期内运行在有效的沙箱边界之下。当所有 无。 + +**运行时不变式:** 不发布伴生入口。readiness、terminal buffer 与 process-tree state 都是按 Session 的私有实现状态,backend 不发布独立 lifecycle stream 或 snapshot。 diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 5a21cb370a..a53fa2fa8b 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -49,7 +43,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/terminal/terminal-bash/src/invariant.ts b/packages/terminal/terminal-bash/src/invariant.ts deleted file mode 100644 index 38421dd59b..0000000000 --- a/packages/terminal/terminal-bash/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-terminal-bash`. - * @module @deepseek-ai/dsh-terminal-bash/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-terminal-bash' - -/** Cordis companion plugin name. */ -export const name = 'terminal-bash-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: readiness, terminal buffers, and process-tree state are private per-session - * implementation state, and the backend publishes no independent lifecycle stream or snapshot. - */ -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/terminal/terminal-bash/tsconfig.json b/packages/terminal/terminal-bash/tsconfig.json index b42e1015be..6f50367beb 100644 --- a/packages/terminal/terminal-bash/tsconfig.json +++ b/packages/terminal/terminal-bash/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index bc8bd8dbf4..9b095b3d91 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/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/README.md -README.md: ee55f854899692d28fae12e77ed9ecb235176019 -README.zh.md: 62147523c419470e1fcd231ccbb17da7ba07871e +README.md: 99b2a687cf7e1a581c48ebbc877bf226180077a1 +README.zh.md: 00695299ad54320bbc036f6818c84db7b07b91bc diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index ee55f85489..99b2a687cf 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -75,7 +75,7 @@ The service owns everything except terminal mechanics: session identity, publica |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`: backend registry, spawn/send/read/signal/kill/list, owner cleanups, disposal | | [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set, error codes | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the registries are private mutable state) | +| — | No runtime invariant companion is published; backend and owner-scoped session registries are private mutable state, and the service exposes neither an independent lifecycle stream nor an unscoped snapshot. | ### Data model and lifecycle diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index 62147523c4..00695299ad 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -75,7 +75,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`:后端注册表、spawn/send/read/signal/kill/list、所有者清理与 dispose | | [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合、错误码 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;注册表是私有可变状态) | +| — | 不发布运行时不变式伴生入口;注册表是私有可变状态。 | ### 数据模型与生命周期 diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index aed22656b7..1d77221704 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -18,29 +18,22 @@ "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-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/terminal/terminal/src/invariant.ts b/packages/terminal/terminal/src/invariant.ts deleted file mode 100644 index c264a1c324..0000000000 --- a/packages/terminal/terminal/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-terminal`. - * @module @deepseek-ai/dsh-terminal/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-terminal' - -/** Cordis companion plugin name. */ -export const name = 'terminal-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: backend and owner-scoped session registries are private mutable state, - * and the service exposes neither an independent lifecycle stream nor an unscoped snapshot. - */ -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/terminal/terminal/tsconfig.json b/packages/terminal/terminal/tsconfig.json index 53d67ac36a..46a562bf63 100644 --- a/packages/terminal/terminal/tsconfig.json +++ b/packages/terminal/terminal/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../util/brand" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/terminal/tool-terminal/README.i18n.yaml b/packages/terminal/tool-terminal/README.i18n.yaml index 96d6274643..b294534dbf 100644 --- a/packages/terminal/tool-terminal/README.i18n.yaml +++ b/packages/terminal/tool-terminal/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/tool-terminal/README.md -README.md: 0ccdb451e829f24cffb0f2c89d4d70ffe9d40c68 -README.zh.md: f7852bd8185086e4f0456d1c79821e71ef31472c +README.md: 0f06603a5bae317343833a790c6264da84eb5f78 +README.zh.md: f256314eda3b535627ec3dee01ed5a00d4ae97e5 diff --git a/packages/terminal/tool-terminal/README.md b/packages/terminal/tool-terminal/README.md index 0ccdb451e8..0f06603a5b 100644 --- a/packages/terminal/tool-terminal/README.md +++ b/packages/terminal/tool-terminal/README.md @@ -182,3 +182,5 @@ These limits define the model-facing surface that is absent. They are current pa None. + +**Runtime invariant:** No companion is published. This stateless adapter contributes tools and prompt guidance, while PTY lifecycle and background-job relationships remain owned by the services it composes. diff --git a/packages/terminal/tool-terminal/README.zh.md b/packages/terminal/tool-terminal/README.zh.md index f7852bd818..f256314eda 100644 --- a/packages/terminal/tool-terminal/README.zh.md +++ b/packages/terminal/tool-terminal/README.zh.md @@ -182,3 +182,5 @@ spawn 返回 id 与有界启动输出。发送与读取返回有界终端文本 无。 + +**运行时不变式:** 不发布伴生入口。这个无状态 adapter 只贡献 tool 与 prompt guidance;PTY lifecycle 与 background-job 关系属于其组合的 service。 diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index e76ec91aa7..e281026e99 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -18,16 +18,11 @@ "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", @@ -36,7 +31,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", @@ -49,7 +43,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", diff --git a/packages/terminal/tool-terminal/src/invariant.ts b/packages/terminal/tool-terminal/src/invariant.ts deleted file mode 100644 index 26f997b77d..0000000000 --- a/packages/terminal/tool-terminal/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-terminal`. - * @module @deepseek-ai/dsh-tool-terminal/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-tool-terminal' - -/** Cordis companion plugin name. */ -export const name = 'tool-terminal-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this stateless adapter contributes tools and prompt guidance, while PTY - * lifecycle and background-job relationships remain owned by the services it composes. - */ -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/terminal/tool-terminal/tsconfig.json b/packages/terminal/tool-terminal/tsconfig.json index fe4eb3be6e..e6605fc49c 100644 --- a/packages/terminal/tool-terminal/tsconfig.json +++ b/packages/terminal/tool-terminal/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../util/output-retention" }, @@ -34,9 +37,6 @@ }, { "path": "../../jobs/jobs" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/test-support/agent-loop-testkit/README.i18n.yaml b/packages/test-support/agent-loop-testkit/README.i18n.yaml index fa0c5af247..7a4efa8491 100644 --- a/packages/test-support/agent-loop-testkit/README.i18n.yaml +++ b/packages/test-support/agent-loop-testkit/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/agent-loop-testkit/README.md -README.md: 26fca33c24c19ac25a00162943e085c2d753efc7 -README.zh.md: 679a586bce4923283f9eee6a05f511a7733e88f2 +README.md: 7b935594636e265bf4f808f692bba2fae0f83ddf +README.zh.md: fc9dc688a1e7d4303f7744ed47c7da2d0fbbf67f diff --git a/packages/test-support/agent-loop-testkit/README.md b/packages/test-support/agent-loop-testkit/README.md index 26fca33c24..7b93559463 100644 --- a/packages/test-support/agent-loop-testkit/README.md +++ b/packages/test-support/agent-loop-testkit/README.md @@ -63,7 +63,7 @@ This section explains the design of the helper; the observable behavior is fully ### Design -The helper is one function, `mountAgentLoopTestDependencies`, that mounts five service plugins in a fixed dependency order — LLM, session, system-prompt, tool registry, then agent registry — and deliberately stops before `AgentLoop` itself, so the caller controls loop load order and the topology under test. Ownership stays with the caller's context: every mounted service is context-owned, a plugin-load failure rejects the promise, and earlier services unwind with the context. The implementation lives in [`src/index.ts`](src/index.ts); the [`src/invariant.ts`](src/invariant.ts) companion declares no runtime invariant because the package owns no production event stream or mutable data — consuming test suites exercise its behavior. +**Runtime invariant:** No companion is published. This test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior. diff --git a/packages/test-support/agent-loop-testkit/README.zh.md b/packages/test-support/agent-loop-testkit/README.zh.md index 679a586bce..fc9dc688a1 100644 --- a/packages/test-support/agent-loop-testkit/README.zh.md +++ b/packages/test-support/agent-loop-testkit/README.zh.md @@ -63,7 +63,7 @@ await ctx.plugin(AgentLoop, { agents: [] }) ### 设计 -该辅助函数是单个函数 `mountAgentLoopTestDependencies`,按固定依赖顺序——LLM、会话、系统提示词、工具注册表、agent 注册表——挂载五个服务插件,并刻意在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序与待测拓扑。所有权留在调用方的上下文:每个已挂载服务都归上下文所有,插件加载失败会拒绝 promise,较早的服务随上下文一起解除。实现位于 [`src/index.ts`](src/index.ts);[`src/invariant.ts`](src/invariant.ts) 配套入口声明无运行时不变式,因为本包不拥有任何生产事件流或可变数据——消费它的测试套件会检验其行为。 +**运行时不变式:** 不发布伴生入口。本包不持有生产事件流或可变数据;消费它的测试套件会直接检验 harness 行为。 diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 9616ed925c..39e7d36de8 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -43,7 +37,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/test-support/agent-loop-testkit/src/invariant.ts b/packages/test-support/agent-loop-testkit/src/invariant.ts deleted file mode 100644 index c9e346921e..0000000000 --- a/packages/test-support/agent-loop-testkit/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-agent-loop-testkit`. - * @module @deepseek-ai/dsh-agent-loop-testkit/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-agent-loop-testkit' - -/** Cordis companion plugin name. */ -export const name = 'agent-loop-testkit-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this test-support package owns no production event stream or mutable data; - * consuming test suites exercise its behavior. - */ -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/test-support/agent-loop-testkit/tsconfig.json b/packages/test-support/agent-loop-testkit/tsconfig.json index e425e87e2e..5e5b3c47f2 100644 --- a/packages/test-support/agent-loop-testkit/tsconfig.json +++ b/packages/test-support/agent-loop-testkit/tsconfig.json @@ -28,9 +28,6 @@ }, { "path": "../../core/tools" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 17eb6ee673..a1c05ed7d8 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: bf8c751b77cd5989ee4174b98d7e945c7a20347d -README.zh.md: e3035049a2e2a2bdd6320f448da3b93b05381866 +README.md: a36a1993a8c8671a9b3c2a4075b32a1bfe7916b4 +README.zh.md: 28cf3f1725a4ba1dbcfeee5a3fdbd639a6e9e48d diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index bf8c751b77..a36a1993a8 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -81,7 +81,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the mounted production packages own theirs) | +| — | No runtime invariant companion is published; this test-support package owns no production event stream or mutable data — it assembles the runtime SlotRegistry and renderer (whose packages own their invariants) around test doubles; its own behavior is exercised by its package tests. | ### Lifecycle diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index e3035049a2..28cf3f1725 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -81,7 +81,7 @@ await runtime.dispose() | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;所挂载的生产包拥有各自的不变式) | +| — | 不发布运行时不变式伴生入口;所挂载的生产包拥有各自的不变式。 | ### 生命周期 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 427a2a6b2d..83447b9782 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -18,10 +18,6 @@ "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" }, @@ -43,7 +39,6 @@ "@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/dsh-subagent": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -62,7 +57,6 @@ "@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/dsh-subagent": "workspace:^", "@types/react": "~18.3.1", @@ -73,7 +67,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.d.ts" ] } diff --git a/packages/test-support/client-runtime/src/invariant.ts b/packages/test-support/client-runtime/src/invariant.ts deleted file mode 100644 index b090377ab5..0000000000 --- a/packages/test-support/client-runtime/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`. - * @module @deepseek-ai/dsh-client-test-runtime/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime' - -/** Cordis companion plugin name. */ -export const name = 'client-test-runtime-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this test-support package owns no production event - * stream or mutable data — it assembles the runtime SlotRegistry and renderer - * (whose packages own their invariants) around test doubles; its own behavior - * is exercised by its package tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/test-support/client-runtime/tests/invariant.client.spec.ts b/packages/test-support/client-runtime/tests/invariant.client.spec.ts deleted file mode 100644 index 91afc835c7..0000000000 --- a/packages/test-support/client-runtime/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index f1e2f9a8d1..a62674b435 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -49,9 +49,6 @@ }, { "path": "../../subagent/subagent" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/test-support/client-runtime/tsdown.config.ts b/packages/test-support/client-runtime/tsdown.config.ts index cf8bad6dfb..55740da3c7 100644 --- a/packages/test-support/client-runtime/tsdown.config.ts +++ b/packages/test-support/client-runtime/tsdown.config.ts @@ -2,5 +2,5 @@ import { clientLibrary } from '../../client/tsdown.client.ts' export default clientLibrary( '@deepseek-ai/dsh-client-test-runtime', - ['lib/types/index.js', 'lib/types/invariant.js'], + ['lib/types/index.js'], ) diff --git a/packages/test-support/llm-mock-server/README.i18n.yaml b/packages/test-support/llm-mock-server/README.i18n.yaml index 6d431ebd86..3191e37fe1 100644 --- a/packages/test-support/llm-mock-server/README.i18n.yaml +++ b/packages/test-support/llm-mock-server/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/llm-mock-server/README.md -README.md: 04fd65a9266837087ebb5da385832e5d40524a70 -README.zh.md: f3ab56557d44328f6c6bb78d03f5b8f96f5131f5 +README.md: 39e41f47469e06f1638a8c90ca5f9d0ce46a4d67 +README.zh.md: 983522beb50236acc3e94cee84bb95b1e591e75f diff --git a/packages/test-support/llm-mock-server/README.md b/packages/test-support/llm-mock-server/README.md index 04fd65a926..39e41f4746 100644 --- a/packages/test-support/llm-mock-server/README.md +++ b/packages/test-support/llm-mock-server/README.md @@ -117,7 +117,7 @@ The server is built on one rule: each accepted chat-completions request consumes | [`src/index.ts`](src/index.ts) | `startMockLlmServer`: listener, behavior table, seeded randomness, telemetry, captured request records | | [`src/cli.ts`](src/cli.ts) | `--sequence` and timing/content option parsing, JSONL stdout telemetry | | [`src/bin.ts`](src/bin.ts) | The `pnpm run mock:llm` source entry | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; wire behavior is exercised through HTTP tests) | +| — | No runtime invariant companion is published; this standalone test server owns no Cordis event stream or shared data; its wire behavior and lifecycle are exercised through direct HTTP and assembled-loop tests. | ### Wire flow diff --git a/packages/test-support/llm-mock-server/README.zh.md b/packages/test-support/llm-mock-server/README.zh.md index f3ab56557d..983522beb5 100644 --- a/packages/test-support/llm-mock-server/README.zh.md +++ b/packages/test-support/llm-mock-server/README.zh.md @@ -117,7 +117,7 @@ CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-s | [`src/index.ts`](src/index.ts) | `startMockLlmServer`:listener、行为表、种子随机、遥测、捕获的请求记录 | | [`src/cli.ts`](src/cli.ts) | `--sequence` 与时序/内容选项解析、JSONL stdout 遥测 | | [`src/bin.ts`](src/bin.ts) | `pnpm run mock:llm` 源入口 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;协议行为通过 HTTP 测试检验) | +| — | 不发布运行时不变式伴生入口;协议行为通过 HTTP 测试检验。 | ### 协议流程 diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index e49f324231..d67f53154c 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/test-support/llm-mock-server/src/invariant.ts b/packages/test-support/llm-mock-server/src/invariant.ts deleted file mode 100644 index a77bb42835..0000000000 --- a/packages/test-support/llm-mock-server/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-llm-mock-server`. - * @module @deepseek-ai/dsh-llm-mock-server/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-llm-mock-server' - -/** Cordis companion plugin name. */ -export const name = 'llm-mock-server-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this standalone test server owns no Cordis event stream or shared data; - * its wire behavior and lifecycle are exercised through direct HTTP and assembled-loop tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/test-support/llm-mock-server/tests/invariant.spec.ts b/packages/test-support/llm-mock-server/tests/invariant.spec.ts deleted file mode 100644 index bea02e2c94..0000000000 --- a/packages/test-support/llm-mock-server/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as MockServerInvariant from '../src/invariant.ts' - -describe('mock LLM server invariant companion', () => { - it('registers its explained empty runtime invariant', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry) - const fiber = await ctx.plugin(MockServerInvariant) - - expect(() => { - ctx.invariants.register('@deepseek-ai/dsh-llm-mock-server', () => {}) - }).toThrow(/already registered/) - await fiber.dispose() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/test-support/llm-mock-server/tsconfig.json b/packages/test-support/llm-mock-server/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/test-support/llm-mock-server/tsconfig.json +++ b/packages/test-support/llm-mock-server/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/test-support/llm-mock-server/tsdown.config.ts b/packages/test-support/llm-mock-server/tsdown.config.ts index 8d0f040df6..2b14e9dfb9 100644 --- a/packages/test-support/llm-mock-server/tsdown.config.ts +++ b/packages/test-support/llm-mock-server/tsdown.config.ts @@ -6,8 +6,4 @@ export default defineConfig([ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, }, - { - entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, ]) diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml index c81afb65ee..410a2bba4a 100644 --- a/packages/test-support/llm-replay/README.i18n.yaml +++ b/packages/test-support/llm-replay/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/llm-replay/README.md -README.md: 54e37e4159788375e61539a6581f871dd35049ff -README.zh.md: d6130daee619e4726ba4117d14dc0767e07d893f +README.md: c0643bf6823e96cf3663f8299d0f5645d7974011 +README.zh.md: 1c2fe3393691b4988678497aa5948957f678c80b diff --git a/packages/test-support/llm-replay/README.md b/packages/test-support/llm-replay/README.md index 54e37e4159..c0643bf682 100644 --- a/packages/test-support/llm-replay/README.md +++ b/packages/test-support/llm-replay/README.md @@ -102,7 +102,7 @@ Replay is built on one idea: the projected session log is the fixture. `deriveRe | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Types, fixture derivation, override validation, placeholder resolution, session binding, `installLlmReplay`, and the plugin export | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the stream grammar is checked by the LLM companion and derivation tests) | +| — | No runtime invariant companion is published; this test-only adapter consumes a fixed replay script; its stream grammar is checked by the LLM companion and fixture derivation tests. | ### Binding and stream flow diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md index d6130daee6..1c2fe33936 100644 --- a/packages/test-support/llm-replay/README.zh.md +++ b/packages/test-support/llm-replay/README.zh.md @@ -102,7 +102,7 @@ fixture 是运行一次真实 agent 所产生的持久化会话日志(` {} - -/** - * 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/test-support/llm-replay/tsconfig.json b/packages/test-support/llm-replay/tsconfig.json index 5c84dff52e..76d3c0af5f 100644 --- a/packages/test-support/llm-replay/tsconfig.json +++ b/packages/test-support/llm-replay/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/test-support/loader-smoke/README.i18n.yaml b/packages/test-support/loader-smoke/README.i18n.yaml index 100ed34810..e5bfc8035b 100644 --- a/packages/test-support/loader-smoke/README.i18n.yaml +++ b/packages/test-support/loader-smoke/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/loader-smoke/README.md -README.md: 7e080273fd4ac9cb2745f6c722d3ed17057af208 -README.zh.md: 3973a0602a6451c64f4d145240a4e6f9a3a7f5d7 +README.md: 162522a388da37a94306b552a60f3f249c7b7dbe +README.zh.md: 449c44e143cc90d8245f8ecb220c9d4f45cc1de1 diff --git a/packages/test-support/loader-smoke/README.md b/packages/test-support/loader-smoke/README.md index 7e080273fd..162522a388 100644 --- a/packages/test-support/loader-smoke/README.md +++ b/packages/test-support/loader-smoke/README.md @@ -77,7 +77,7 @@ The harness is built on one separation: the smoke runs in a child process under |---|---| | [`src/index.ts`](src/index.ts) | Mode resolver, `runLoaderSmoke` subprocess harness, options and result types | | [`src/agent-turn.ts`](src/agent-turn.ts) | `runFixtureTurn` direct-agent driver and result envelope | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; consuming test suites exercise the harness) | +| — | No runtime invariant companion is published; this test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior. | diff --git a/packages/test-support/loader-smoke/README.zh.md b/packages/test-support/loader-smoke/README.zh.md index 3973a0602a..449c44e143 100644 --- a/packages/test-support/loader-smoke/README.zh.md +++ b/packages/test-support/loader-smoke/README.zh.md @@ -77,7 +77,7 @@ harness 建立在一个分离之上:冒烟测试在隔离世界中的子进程 |---|---| | [`src/index.ts`](src/index.ts) | 模式解析器、`runLoaderSmoke` 子进程 harness、选项与结果类型 | | [`src/agent-turn.ts`](src/agent-turn.ts) | `runFixtureTurn` 直接 agent driver 与结果信封 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;消费它的测试套件会检验该 harness) | +| — | 不发布运行时不变式伴生入口;消费它的测试套件会检验该 harness。 | diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 067b179cd0..30cba0bebd 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -18,16 +18,11 @@ "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", @@ -37,7 +32,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -45,7 +39,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/test-support/loader-smoke/src/invariant.ts b/packages/test-support/loader-smoke/src/invariant.ts deleted file mode 100644 index 36e8d646de..0000000000 --- a/packages/test-support/loader-smoke/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-loader-smoke`. - * @module @deepseek-ai/dsh-loader-smoke/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-loader-smoke' - -/** Cordis companion plugin name. */ -export const name = 'loader-smoke-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this test-support package owns no production event stream or mutable data; - * consuming test suites exercise its behavior. - */ -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/test-support/loader-smoke/tsconfig.json b/packages/test-support/loader-smoke/tsconfig.json index d593ea20b7..989ad3f0b0 100644 --- a/packages/test-support/loader-smoke/tsconfig.json +++ b/packages/test-support/loader-smoke/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../../vendor/cordis" + }, { "path": "../../core/agent" }, @@ -16,9 +19,6 @@ }, { "path": "../../core/session" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 35b72fc16e..1a53fac2c0 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/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/session-snapshot/README.md -README.md: 9f1907af8b9e50f587bcc05b93d8e3730c8f272c -README.zh.md: 2d4290578a655df91eb60f63177c97fc5d82da6c +README.md: 9077b3d60a633f68cca9cf241f4fdd67486bc3fc +README.zh.md: 2a5f0940d1b13fa24fecb7a41759b39d47feb4f8 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 9f1907af8b..9077b3d60a 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -116,7 +116,7 @@ The shared core owns manifests, workspace setup/comparison, typed identity mappi | [`src/workspace.ts`](src/workspace.ts) | Scenario workspace setup and complete expected-state comparison | | [`src/suite.ts`](src/suite.ts) | Scenario-table suite factory, fixture guards, record/refresh write-back | | [`src/index.ts`](src/index.ts) | Package entry re-exporting the four layers | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; consuming test suites exercise the kit) | +| — | No runtime invariant companion is published; this test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior. | ### Data flow diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 2d4290578a..2a5f0940d1 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -116,7 +116,7 @@ defineAcpSnapshotSuite({ | [`src/workspace.ts`](src/workspace.ts) | 场景 workspace 设置与完整预期状态比较 | | [`src/suite.ts`](src/suite.ts) | 场景表套件工厂、fixture 保护、录制/刷新回写 | | [`src/index.ts`](src/index.ts) | 再导出四个层的包入口 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;消费它的测试套件会检验该工具包) | +| — | 不发布运行时不变式伴生入口;消费它的测试套件会检验该工具包。 | ### 数据流 diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index 57c4b94d81..121ebcf884 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -18,16 +18,11 @@ "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", @@ -39,7 +34,6 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -47,7 +41,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/test-support/session-snapshot/src/invariant.ts b/packages/test-support/session-snapshot/src/invariant.ts deleted file mode 100644 index e8c78472f5..0000000000 --- a/packages/test-support/session-snapshot/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-snapshot`. - * @module @deepseek-ai/dsh-session-snapshot/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-session-snapshot' - -/** Cordis companion plugin name. */ -export const name = 'session-snapshot-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this test-support package owns no production event stream or mutable data; - * consuming test suites exercise its behavior. - */ -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/test-support/session-snapshot/tsconfig.json b/packages/test-support/session-snapshot/tsconfig.json index b258ab7851..8be01d94b1 100644 --- a/packages/test-support/session-snapshot/tsconfig.json +++ b/packages/test-support/session-snapshot/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../loader-smoke" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../../core/session" } diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 1b4c6b5da2..345d131cae 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/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/generator/README.md -README.md: c6a419467f8f9da71c789dee2b969f69841b57cc -README.zh.md: 196af16d97c9c6eb2774e615840bfaed2454e7f2 +README.md: a3deccbb9ba0847d02b532732a87ec52da1a2d7c +README.zh.md: 5983a4d9ecc7914320718338931d354a8e85d032 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index c6a419467f..a3deccbb9b 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -137,3 +137,5 @@ These limits define what the generator cannot model or emit; they are current pa None. + +**Runtime invariant:** No companion is published. This source-project analyzer and build-time emitter runs outside any cordis runtime; model snapshots, executable artifacts, and consuming-package typechecks enforce its output contract. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 196af16d97..5983a4d9ec 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -137,3 +137,5 @@ Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定 无。 + +**运行时不变式:** 不发布伴生入口。这是运行于 Cordis 之外的源码分析器与构建时 emitter;model snapshot、可执行 artifact 与消费包 typecheck 强制其输出约定。 diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index ec0ffaab52..b596a1c817 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -22,16 +22,11 @@ "types": "./lib/types/tsdown-plugin.d.ts", "default": "./lib/types/tsdown-plugin.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/**/*.js", "lib/types/**/*.d.ts" ], @@ -41,11 +36,9 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/typert/generator/src/invariant.ts b/packages/typert/generator/src/invariant.ts deleted file mode 100644 index 1c153f74f4..0000000000 --- a/packages/typert/generator/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-typert-generator`. - * @module @deepseek-ai/dsh-typert-generator/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-typert-generator' - -/** Cordis companion plugin name. */ -export const name = 'typert-generator-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this source-project analyzer and build-time emitter - * runs outside any cordis runtime; model snapshots, executable artifacts, and - * consuming-package typechecks enforce its output contract. - */ -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/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml index 2ae0aad0b5..a0a81a0c14 100644 --- a/packages/typert/loader/README.i18n.yaml +++ b/packages/typert/loader/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/loader/README.md -README.md: 2591d84186251ac1ee17c40f010cb35d1eb67bce -README.zh.md: 644d6a7db9e72a9ccb48f349837433827bc4696d +README.md: ba8fe7d6b13924b6f9a39d1e792153f346aec530 +README.zh.md: a4c16ac5f5b54e8524422f6574092e909988be95 diff --git a/packages/typert/loader/README.md b/packages/typert/loader/README.md index 2591d84186..ba8fe7d6b1 100644 --- a/packages/typert/loader/README.md +++ b/packages/typert/loader/README.md @@ -77,7 +77,7 @@ Verdicts (resolvable specifier, export presence) and imported manifests are cach | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, scanner, manifest validation, registration wiring | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; the Loader entry lifecycle directly owns each exact registry disposer, and integration tests observe registration and removal. | diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md index 644d6a7db9..a4c16ac5f5 100644 --- a/packages/typert/loader/README.zh.md +++ b/packages/typert/loader/README.zh.md @@ -77,7 +77,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config`、扫描器、manifest 校验、注册接线 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;Loader entry 生命周期直接持有每个准确的 registry disposer,integration test 会观察注册与移除。 | diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index b97a31d84a..a0d83521a0 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -18,22 +18,16 @@ "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/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -42,7 +36,6 @@ }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" diff --git a/packages/typert/loader/src/invariant.ts b/packages/typert/loader/src/invariant.ts deleted file mode 100644 index dc7070c6cc..0000000000 --- a/packages/typert/loader/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-typert-loader`. - * @module @deepseek-ai/dsh-typert-loader/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-typert-loader' - -/** Cordis companion plugin name. */ -export const name = 'typert-loader-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the Loader entry lifecycle directly owns each exact - * registry disposer, and integration tests observe registration and removal. - */ -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/typert/loader/tsconfig.json b/packages/typert/loader/tsconfig.json index 8cee8d5d00..28dbbd9aa6 100644 --- a/packages/typert/loader/tsconfig.json +++ b/packages/typert/loader/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../registry" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 437641bc61..df7861b14a 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: 34e685b841e3bf98d1b23306ed90fd3ca8381f79 -README.zh.md: d85cda322f9a3117a4b7bbe4405140b75d5915b8 +README.md: 83cb9225b379d178e9dec066ce719fc2d56ab2f4 +README.zh.md: 8525c089d77ba894954852327d2065633677a219 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 34e685b841..83cb9225b3 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -84,7 +84,7 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot |---|---| | [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation, `TypertLookupFailure` | | [`src/types.ts`](src/types.ts) | Protocol maps, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; decorators retain private immutable declarations and bindings are frozen values with no independent event stream to cross-check. | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index d85cda322f..8525c089d7 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -84,7 +84,7 @@ Host 装配以转发给消费端的 Cordis 事件扩展 `TypertRemoteEventSelect |---|---| | [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验、`TypertLookupFailure` | | [`src/types.ts`](src/types.ts) | 协议映射、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;decorator 只保留私有不可变声明,binding 也是冻结值,没有可供交叉核对的独立事件流。 | diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 77404df033..ceae9221fd 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -31,17 +27,14 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/typert/protocol/src/invariant.ts b/packages/typert/protocol/src/invariant.ts deleted file mode 100644 index 1bab1f2142..0000000000 --- a/packages/typert/protocol/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-typert-protocol`. - * @module @deepseek-ai/dsh-typert-protocol/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-typert-protocol' - -/** Cordis companion plugin name. */ -export const name = 'typert-protocol-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: decorators retain private immutable declarations and - * bindings are frozen values with no independent event stream to cross-check. - */ -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/typert/protocol/tsconfig.json b/packages/typert/protocol/tsconfig.json index bb46910c07..754725418e 100644 --- a/packages/typert/protocol/tsconfig.json +++ b/packages/typert/protocol/tsconfig.json @@ -13,9 +13,6 @@ }, { "path": "../../../vendor/cordis" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 3762eabb8b..09c20adc17 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: a19226addaa9512eaaefb621d0f5ae70653bf216 -README.zh.md: 8c909bb698f1b6658fe90f9d56f2c0d543957ec9 +README.md: 8238ad1613367d04b44d0b87fb15a0cfde91e325 +README.zh.md: fcba26636e9da42d84cb6c5ab426b6c0023c9a29 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index a19226adda..8238ad1613 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -81,7 +81,7 @@ Keys are stable: `#` for reflection, `#` for schem | [`src/service.ts`](src/service.ts) | `TypertRegistry` service, stores, validation, effect wiring | | [`src/types.ts`](src/types.ts) | Contribution, record, and filter types | | [`src/client/index.ts`](src/client/index.ts) | Client face installing the same registry | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion | +| — | No runtime invariant companion is published; schema and package-reflection records mutate together inside register/dispose, with no independent event or second data source to cross-check; duplicate identities fail at the owning operation boundary. | diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 8c909bb698..fcba26636e 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -81,7 +81,7 @@ Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host | [`src/service.ts`](src/service.ts) | `TypertRegistry` 服务、存储、校验、effect 接线 | | [`src/types.ts`](src/types.ts) | 贡献、记录与过滤器类型 | | [`src/client/index.ts`](src/client/index.ts) | 安装同一注册表的 Client face | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | +| — | 不发布运行时不变式伴生入口;schema 与 package-reflection record 在 register/dispose 内一起变更,没有独立 event 或第二数据源可供交叉核对;重复 identity 在所属操作处失败。 | diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index a567280cf7..4549e495ef 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -18,10 +18,6 @@ "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" @@ -46,7 +42,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" @@ -57,11 +52,9 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/typert/registry/src/invariant.ts b/packages/typert/registry/src/invariant.ts deleted file mode 100644 index 93c63f786c..0000000000 --- a/packages/typert/registry/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-typert-registry`. - * @module @deepseek-ai/dsh-typert-registry/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-typert-registry' - -/** Cordis companion plugin name. */ -export const name = 'typert-registry-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: schema and package-reflection records mutate together - * inside register/dispose, with no independent event or second data source to - * cross-check; duplicate identities fail at the owning operation boundary. - */ -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/typert/registry/tsconfig.json b/packages/typert/registry/tsconfig.json index e857378189..7d6795a110 100644 --- a/packages/typert/registry/tsconfig.json +++ b/packages/typert/registry/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../runtime-diagnostics/invariants" - }, { "path": "../protocol" } diff --git a/packages/typert/registry/tsdown.config.ts b/packages/typert/registry/tsdown.config.ts index e494104c4d..41294d29fd 100644 --- a/packages/typert/registry/tsdown.config.ts +++ b/packages/typert/registry/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js']) diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index 84dba6fd7b..4480882baf 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/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/util/atomic-write/README.md -README.md: 22806b539faaa7668c37d863c20ffced2576bde6 -README.zh.md: 1468f06aa4d46d9ea7c471bbb045314b67ae595e +README.md: 49eebb4b06fea5b43e31f47624f659f96e2725fd +README.zh.md: a87195b7461e762342d1bbfbff9e26b7c9e6c831 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 22806b539f..49eebb4b06 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -75,7 +75,7 @@ The package is built on one separation: the atomic commit owns the swap, and the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `writeFileAtomic` and `withFileLock`, the package's whole surface | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the replacement contract is exercised by unit tests) | +| — | No runtime invariant companion is published; this pure filesystem primitive owns no event stream or mutable runtime data; its replacement contract is enforced by unit tests. | ### Write path diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 1468f06aa4..a87195b746 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -75,7 +75,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `writeFileAtomic` 与 `withFileLock`,即本包的全部接口 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;替换约定由单元测试覆盖) | +| — | 不发布运行时不变式伴生入口;替换约定由单元测试覆盖。 | ### 写入路径 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index ff14c62789..1d8f2ac241 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts deleted file mode 100644 index 241d439cdf..0000000000 --- a/packages/util/atomic-write/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`. - * @module @deepseek-ai/dsh-atomic-write/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-atomic-write' - -/** Cordis companion plugin name. */ -export const name = 'atomic-write-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime - * data; its replacement contract is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts deleted file mode 100644 index 3c6ce085d2..0000000000 --- a/packages/util/atomic-write/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as AtomicWriteInvariant from '../src/invariant.ts' - -describe('atomic-write invariant companion', () => { - it('registers its explained empty runtime invariant', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry) - const fiber = await ctx.plugin(AtomicWriteInvariant) - - expect(() => { - ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {}) - }).toThrow(/already registered/) - await fiber.dispose() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/util/atomic-write/tsconfig.json b/packages/util/atomic-write/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/atomic-write/tsconfig.json +++ b/packages/util/atomic-write/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/brand/README.i18n.yaml b/packages/util/brand/README.i18n.yaml index 66209cb86b..af5a49e0a0 100644 --- a/packages/util/brand/README.i18n.yaml +++ b/packages/util/brand/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/util/brand/README.md -README.md: 879d10107929539b0973e4b0ad3f7f19da7be0ca -README.zh.md: ba1c7e31129d1e58162484ee135210b1176d7499 +README.md: ab66ec266255d447408ba944327a89f9ee9d2425 +README.zh.md: 462cde2e74ef853a7212b9a789bf0fafd7a7159d diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index 879d101079..ab66ec2662 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -61,7 +61,7 @@ The primitive is one intersection type: `string & { readonly [BRAND]: B }`, wher | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | The `Branded` type and the private `BRAND` symbol — the whole package | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; erasure is enforced by the compiler) | +| — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### How erasure works diff --git a/packages/util/brand/README.zh.md b/packages/util/brand/README.zh.md index ba1c7e3112..462cde2e74 100644 --- a/packages/util/brand/README.zh.md +++ b/packages/util/brand/README.zh.md @@ -61,7 +61,7 @@ export function SessionId(id: string): SessionId { | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `Branded` 类型与私有 `BRAND` 符号——即整个包 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;擦除由编译器保证) | +| — | 不发布运行时不变式伴生入口;擦除由编译器保证。 | ### 擦除如何工作 diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index b19ebbdb9b..016c4ce026 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts deleted file mode 100644 index cd33bf10cb..0000000000 --- a/packages/util/brand/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-brand`. - * @module @deepseek-ai/dsh-brand/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-brand' - -/** Cordis companion plugin name. */ -export const name = 'brand-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value - * algebra is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/brand/tsconfig.json +++ b/packages/util/brand/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/crypto/README.i18n.yaml b/packages/util/crypto/README.i18n.yaml index d16fef11a6..446b626dce 100644 --- a/packages/util/crypto/README.i18n.yaml +++ b/packages/util/crypto/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/util/crypto/README.md -README.md: d52e847755465c45612c6cdd7f75fc464201ac88 -README.zh.md: 248fb4bcd51f6787aaf40892c840eb2d4aec553b +README.md: 5f35d5165603e49392b09d37f1eb5ba33c9e954f +README.zh.md: 6ddd43c2adbff723fe77a8bf3261e861ed6c08b1 diff --git a/packages/util/crypto/README.md b/packages/util/crypto/README.md index d52e847755..5f35d51656 100644 --- a/packages/util/crypto/README.md +++ b/packages/util/crypto/README.md @@ -67,3 +67,5 @@ No direct invalidation; identifier-minting consumers own any request changes. None. + +**Runtime invariant:** No companion is published. This pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. diff --git a/packages/util/crypto/README.zh.md b/packages/util/crypto/README.zh.md index 248fb4bcd5..6ddd43c2ad 100644 --- a/packages/util/crypto/README.zh.md +++ b/packages/util/crypto/README.zh.md @@ -67,3 +67,5 @@ import { bytesToBase64, randomUUID, type Uuid } from '@deepseek-ai/dsh-util-cryp 无。 + +**运行时不变式:** 不发布伴生入口。这个纯工具不持有事件流或可变运行时数据;其值运算由单元测试覆盖。 diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 9abf359b5c..e082cc404e 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/crypto/src/invariant.ts b/packages/util/crypto/src/invariant.ts deleted file mode 100644 index e889ba1023..0000000000 --- a/packages/util/crypto/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-util-crypto`. - * @module @deepseek-ai/dsh-util-crypto/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-util-crypto' - -/** Cordis companion plugin name. */ -export const name = 'util-crypto-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value - * algebra is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/crypto/tsconfig.json b/packages/util/crypto/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/crypto/tsconfig.json +++ b/packages/util/crypto/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/home-paths/README.i18n.yaml b/packages/util/home-paths/README.i18n.yaml index 6a6d8e535e..b21044dffe 100644 --- a/packages/util/home-paths/README.i18n.yaml +++ b/packages/util/home-paths/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/util/home-paths/README.md -README.md: 33f6062430ee525009ce63a95521d1f521d6466a -README.zh.md: 2ad4ec53021c6051b95b621e62a81231dbfd0129 +README.md: ff29f9425b8c9f828b168649223a02ebbc4e5145 +README.zh.md: 321fcc56ca544baafefca667060b4f82250f5e00 diff --git a/packages/util/home-paths/README.md b/packages/util/home-paths/README.md index 33f6062430..ff29f9425b 100644 --- a/packages/util/home-paths/README.md +++ b/packages/util/home-paths/README.md @@ -64,7 +64,7 @@ The package is built on one principle: all harness user data lives under one roo | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Home resolution, path joining, display, tilde expansion, and watch-path canonicalization | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the resolution rules are exercised by unit tests) | +| — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### Resolution rules diff --git a/packages/util/home-paths/README.zh.md b/packages/util/home-paths/README.zh.md index 2ad4ec5302..321fcc56ca 100644 --- a/packages/util/home-paths/README.zh.md +++ b/packages/util/home-paths/README.zh.md @@ -64,7 +64,7 @@ const settings = dshHomePath('settings') // join one child onto the resolved | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 主目录解析、路径拼接、展示、波浪号展开与监听路径规范化 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;解析规则由单元测试覆盖) | +| — | 不发布运行时不变式伴生入口;解析规则由单元测试覆盖。 | ### 解析规则 diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 79d5f4d619..ba8e099cb4 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/home-paths/src/invariant.ts b/packages/util/home-paths/src/invariant.ts deleted file mode 100644 index 4ecce19e78..0000000000 --- a/packages/util/home-paths/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-home-paths`. - * @module @deepseek-ai/dsh-home-paths/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-home-paths' - -/** Cordis companion plugin name. */ -export const name = 'home-paths-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value - * algebra is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/home-paths/tsconfig.json b/packages/util/home-paths/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/home-paths/tsconfig.json +++ b/packages/util/home-paths/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/launch-environment/README.i18n.yaml b/packages/util/launch-environment/README.i18n.yaml index bd22df7ad1..83cdcd7d55 100644 --- a/packages/util/launch-environment/README.i18n.yaml +++ b/packages/util/launch-environment/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/util/launch-environment/README.md -README.md: ed369075ac592617a36470a38b1735f82c71fb2c -README.zh.md: 0711420b99625635c88a1c6430b35819007504d0 +README.md: 312952f527af02c9bd9b19d3560d65c84b446b7d +README.zh.md: 3071052c941737b365d1557ead005ede5ba857d3 diff --git a/packages/util/launch-environment/README.md b/packages/util/launch-environment/README.md index ed369075ac..312952f527 100644 --- a/packages/util/launch-environment/README.md +++ b/packages/util/launch-environment/README.md @@ -66,7 +66,7 @@ The snapshot is built on one separation: the launcher owns which files exist, an | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `createLaunchEnvironmentSnapshot`, `launchEnvironmentOf`, and the `ctx.launchEnvironment` slot | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the snapshot is frozen before any fiber starts) | +| — | No runtime invariant companion is published; the snapshot is frozen before any fiber starts and this package owns no event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests. | ### How the snapshot stays frozen diff --git a/packages/util/launch-environment/README.zh.md b/packages/util/launch-environment/README.zh.md index 0711420b99..3071052c94 100644 --- a/packages/util/launch-environment/README.zh.md +++ b/packages/util/launch-environment/README.zh.md @@ -66,7 +66,7 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `createLaunchEnvironmentSnapshot`、`launchEnvironmentOf` 与 `ctx.launchEnvironment` 槽位 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;快照在任何 fiber 启动前即已冻结) | +| — | 不发布运行时不变式伴生入口;快照在任何 fiber 启动前即已冻结。 | ### 快照如何保持冻结 diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 0e3c7667c6..eb12832159 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/launch-environment/src/invariant.ts b/packages/util/launch-environment/src/invariant.ts deleted file mode 100644 index 7bcf06bb0c..0000000000 --- a/packages/util/launch-environment/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-launch-environment`. - * @module @deepseek-ai/dsh-launch-environment/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-launch-environment' - -/** Cordis companion plugin name. */ -export const name = 'launch-environment-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no - * event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/launch-environment/tsconfig.json b/packages/util/launch-environment/tsconfig.json index 779effc3cc..f1a449634c 100644 --- a/packages/util/launch-environment/tsconfig.json +++ b/packages/util/launch-environment/tsconfig.json @@ -9,7 +9,7 @@ ], "references": [ { - "path": "../../runtime-diagnostics/invariants" + "path": "../../../vendor/cordis" } ] } diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 65d2edff25..46c49a2810 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: 282cefcc00d296b3c09741e64c88647343e1bef2 -README.zh.md: aecb60d2e8f68a6e9468e3f2d4b0e2d99bebefc6 +README.md: 06baa6d631198e42c6de3e20c8a0bd562564b779 +README.zh.md: 6df6ee25e32e5f7290af1c4af580fe108a0d3b9f diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 282cefcc00..06baa6d631 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -64,7 +64,7 @@ The command runner is a thin wrapper over Node's `execFile`. The path opener sel | [`src/index.ts`](src/index.ts) | Public command-runner and path-opener exports | | [`src/runner.ts`](src/runner.ts) | Shell-free `execFile` adapter | | [`src/path-opener.ts`](src/path-opener.ts) | Desktop detection, open intents, browser preference, and WSL translation | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; each run is one stateless child-process round trip) | +| — | No runtime invariant companion is published; each run is one stateless child-process round trip with no owned event stream or mutable runtime data; behavior is enforced by unit tests. | ### What execFile gives the runner diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index aecb60d2e8..6df6ee25e3 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -64,7 +64,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s | [`src/index.ts`](src/index.ts) | 命令运行器与路径打开器的公共导出 | | [`src/runner.ts`](src/runner.ts) | 无 shell 的 `execFile` 适配器 | | [`src/path-opener.ts`](src/path-opener.ts) | 桌面探测、打开意图、浏览器偏好与 WSL 转换 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;每次运行都是一次无状态的子进程往返) | +| — | 不发布运行时不变式伴生入口;每次运行都是一次无状态的子进程往返。 | ### execFile 给了运行器什么 diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 69a1194411..315e3dc25b 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/native-command/src/invariant.ts b/packages/util/native-command/src/invariant.ts deleted file mode 100644 index 0504f06e3e..0000000000 --- a/packages/util/native-command/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-native-command`. - * @module @deepseek-ai/dsh-native-command/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-native-command' - -/** Cordis companion plugin name. */ -export const name = 'native-command-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: each run is one stateless child-process round trip - * with no owned event stream or mutable runtime data; behavior is enforced by - * unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/native-command/tsconfig.json b/packages/util/native-command/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/native-command/tsconfig.json +++ b/packages/util/native-command/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/output-retention/README.i18n.yaml b/packages/util/output-retention/README.i18n.yaml index 77cfe4dca2..b4c01d4768 100644 --- a/packages/util/output-retention/README.i18n.yaml +++ b/packages/util/output-retention/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/util/output-retention/README.md -README.md: 6614f82149728b12419a4750a59772ef9e5de440 -README.zh.md: 4f8d6bb235b7ee4c0b79a094308ad6c4a5913e36 +README.md: 184f1da7a5d3e1d9369382404e43605d79cb354e +README.zh.md: dd0f041d78af8ed9d03e1fd4191ee171b9425aae diff --git a/packages/util/output-retention/README.md b/packages/util/output-retention/README.md index 6614f82149..184f1da7a5 100644 --- a/packages/util/output-retention/README.md +++ b/packages/util/output-retention/README.md @@ -105,7 +105,7 @@ The library is built on one separation: it owns the mechanical question of what | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `ItemRetainer`, `TextRetainer`, `describeOmitted`, and `formatRetentionNotice` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the retention algebra is exercised by unit tests) | +| — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### Two retainers, two resource models diff --git a/packages/util/output-retention/README.zh.md b/packages/util/output-retention/README.zh.md index 4f8d6bb235..dd0f041d78 100644 --- a/packages/util/output-retention/README.zh.md +++ b/packages/util/output-retention/README.zh.md @@ -105,7 +105,7 @@ const footer = formatRetentionNotice( | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `ItemRetainer`、`TextRetainer`、`describeOmitted` 与 `formatRetentionNotice` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;保留运算由单元测试覆盖) | +| — | 不发布运行时不变式伴生入口;保留运算由单元测试覆盖。 | ### 两个 retainer,两种资源模型 diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 7333dfe9f2..af69a8aa2f 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/output-retention/src/invariant.ts b/packages/util/output-retention/src/invariant.ts deleted file mode 100644 index a47e3b1e69..0000000000 --- a/packages/util/output-retention/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-output-retention`. - * @module @deepseek-ai/dsh-output-retention/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-output-retention' - -/** Cordis companion plugin name. */ -export const name = 'output-retention-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value - * algebra is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/output-retention/tsconfig.json b/packages/util/output-retention/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/output-retention/tsconfig.json +++ b/packages/util/output-retention/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml index bcca20e968..485129786c 100644 --- a/packages/util/timeout/README.i18n.yaml +++ b/packages/util/timeout/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/util/timeout/README.md -README.md: f446f9b33edad5badc1218802b27547ef116f7db -README.zh.md: 31b4c4f5d42777b2a2439f85d3d517ec6cd435f9 +README.md: 2c633aea176ffb268fa7ad5cf914944296165f92 +README.zh.md: 9681bf953268a3e99768f257e065a159f73148ad diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index f446f9b33e..2c633aea17 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -92,7 +92,7 @@ The library is built on one boundary: share the timing and classification, keep | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `clampTimeout`, `deadline`, `idleWatchdog`, `timeoutOf`, `TimeoutReason`, `MAX_TIMER_DELAY_MS` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the timing algebra is exercised by unit tests) | +| — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### How a deadline fuses sources diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md index 31b4c4f5d4..9681bf9532 100644 --- a/packages/util/timeout/README.zh.md +++ b/packages/util/timeout/README.zh.md @@ -92,7 +92,7 @@ timer 只在某个迭代器 `next()` 尚未完成时启动,并会因不产生 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `clampTimeout`、`deadline`、`idleWatchdog`、`timeoutOf`、`TimeoutReason`、`MAX_TIMER_DELAY_MS` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;时序运算由单元测试覆盖) | +| — | 不发布运行时不变式伴生入口;时序运算由单元测试覆盖。 | ### deadline 如何融合来源 diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 1e5471055f..3ebb93db13 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts deleted file mode 100644 index 1284ecf8da..0000000000 --- a/packages/util/timeout/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-timeout`. - * @module @deepseek-ai/dsh-timeout/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-timeout' - -/** Cordis companion plugin name. */ -export const name = 'timeout-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value - * algebra is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/timeout/tsconfig.json b/packages/util/timeout/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/timeout/tsconfig.json +++ b/packages/util/timeout/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml index dbe166db88..bd0d7232a7 100644 --- a/packages/util/workspace-path/README.i18n.yaml +++ b/packages/util/workspace-path/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/util/workspace-path/README.md -README.md: 9f925ba09e7dd5e015cb5000490edb0234463abc -README.zh.md: 09a2449d084c2110f47037e4368c5e6c66d6a81c +README.md: 72addf2d85c62ef5afbfc4824af8c96c59bfbecb +README.zh.md: 2a115f1a9dbdb23b7121dea41eb6da75b1515358 diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md index 9f925ba09e..72addf2d85 100644 --- a/packages/util/workspace-path/README.md +++ b/packages/util/workspace-path/README.md @@ -35,3 +35,5 @@ Browser-safe path helpers shared by Workspace-facing client and controller packa None. + +**Runtime invariant:** No companion is published. This utility owns no mutable runtime relationship. diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md index 09a2449d08..2a115f1a9d 100644 --- a/packages/util/workspace-path/README.zh.md +++ b/packages/util/workspace-path/README.zh.md @@ -35,3 +35,5 @@ kind: "package-library" 无。 + +**运行时不变式:** 不发布伴生入口。这个工具不持有可变运行时关系。 diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 348af58fd2..80d1dcb080 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -18,25 +18,18 @@ "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:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/workspace-path/src/invariant.ts b/packages/util/workspace-path/src/invariant.ts deleted file mode 100644 index 7cac9479dd..0000000000 --- a/packages/util/workspace-path/src/invariant.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-util-workspace-path`. - * @module @deepseek-ai/dsh-util-workspace-path/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-util-workspace-path' - -/** Cordis companion plugin name. */ -export const name = 'workspace-path-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** No runtime invariant: this utility owns no mutable runtime relationship. */ -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/util/workspace-path/tsconfig.json b/packages/util/workspace-path/tsconfig.json index 779effc3cc..0b174e83f6 100644 --- a/packages/util/workspace-path/tsconfig.json +++ b/packages/util/workspace-path/tsconfig.json @@ -6,10 +6,5 @@ }, "include": [ "src" - ], - "references": [ - { - "path": "../../runtime-diagnostics/invariants" - } ] } diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 8a5c67d5fe..b01bebdb8a 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/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/web/tool-web/README.md -README.md: 4a2fecdb2a55e7bdd8a761263de8561c0910b21a -README.zh.md: ada0bbdb0388f5deb5a56d722e3b6e7be804008c +README.md: 03f82527c299fdf248e33351def0197088ad3749 +README.zh.md: dca9ad44cb2b119465ea1f03e9aec37c8f366289 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 4a2fecdb2a..03f82527c2 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -103,7 +103,7 @@ The package is built on one separation and one registration rule: | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, enablement, timeout budgets, tool registration | | [`src/search.ts`](src/search.ts) | The `web_search` tool: argument validation, query fan-out, merge, formatting, presentation meta | | [`src/fetch.ts`](src/fetch.ts) | The `web_fetch` tool: HTML→markdown conversion, output caps, formatting, presentation meta | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the tools) | +| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. | ### Search flow diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index ada0bbdb03..dca9ad44cb 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -103,7 +103,7 @@ schema 校验会在执行前拒绝缺失或非数组的 `queries` 字段、非 | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、启用状态、超时预算、工具注册 | | [`src/search.ts`](src/search.ts) | `web_search` 工具:参数校验、查询扇出、合并、格式化、呈现元数据 | | [`src/fetch.ts`](src/fetch.ts) | `web_fetch` 工具:HTML→markdown 转换、输出上限、格式化、呈现元数据 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在工具处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在工具处强制执行。 | ### 搜索流程 diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index e74c467c98..9440212763 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -47,7 +41,6 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@types/turndown": "^5.0.6", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts deleted file mode 100644 index a9ea21c3a9..0000000000 --- a/packages/web/tool-web/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-web`. - * @module @deepseek-ai/dsh-tool-web/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-tool-web' - -/** Cordis companion plugin name. */ -export const name = 'tool-web-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -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/web/tool-web/tsconfig.json b/packages/web/tool-web/tsconfig.json index 5934faf64d..3aa19ea2fd 100644 --- a/packages/web/tool-web/tsconfig.json +++ b/packages/web/tool-web/tsconfig.json @@ -29,9 +29,6 @@ }, { "path": "../web" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/web/web-fetch-http/README.i18n.yaml b/packages/web/web-fetch-http/README.i18n.yaml index 0247a3d65e..6f0d2e854b 100644 --- a/packages/web/web-fetch-http/README.i18n.yaml +++ b/packages/web/web-fetch-http/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/web/web-fetch-http/README.md -README.md: 947a3a77a2298bb25092f7d282971c0a6493a1f1 -README.zh.md: 5f2000a9aa2cc5a88762a47b4dd3ea2992b298e6 +README.md: a3e6da087df37b051aa2dc506eb718b4876601de +README.zh.md: 8bcdc8da8229a0251f520b52744811088a9b974c diff --git a/packages/web/web-fetch-http/README.md b/packages/web/web-fetch-http/README.md index 947a3a77a2..a3e6da087d 100644 --- a/packages/web/web-fetch-http/README.md +++ b/packages/web/web-fetch-http/README.md @@ -92,7 +92,7 @@ The package is built on one separation and one layered timeout: | [`src/provider.ts`](src/provider.ts) | The `HttpFetchProvider`: pinned transport, redirect following, capped reads, charset decoding | | [`src/network.ts`](src/network.ts) | Public-address resolution, DNS64 discovery, and connection pinning | | [`src/policy.ts`](src/policy.ts) | URL validation, same-origin checks, content-type classification, charset parsing | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; limits are enforced in the provider) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Read path diff --git a/packages/web/web-fetch-http/README.zh.md b/packages/web/web-fetch-http/README.zh.md index 5f2000a9aa..8bcdc8da82 100644 --- a/packages/web/web-fetch-http/README.zh.md +++ b/packages/web/web-fetch-http/README.zh.md @@ -92,7 +92,7 @@ const page = await ctx.web.fetch({ url: 'https://example.com' }) | [`src/provider.ts`](src/provider.ts) | `HttpFetchProvider`:固定连接、重定向跟随、有界读取、charset 解码 | | [`src/network.ts`](src/network.ts) | 公开地址解析、DNS64 发现与连接固定 | | [`src/policy.ts`](src/policy.ts) | URL 校验、同源检查、内容类型分类、charset 解析 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;上限在提供方处强制执行) | +| — | 不发布运行时不变式伴生入口;上限在提供方处强制执行。 | ### 读取路径 diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index dc0697c111..b49d93683b 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -18,22 +18,16 @@ "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/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^" }, @@ -44,7 +38,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^" } diff --git a/packages/web/web-fetch-http/src/invariant.ts b/packages/web/web-fetch-http/src/invariant.ts deleted file mode 100644 index cd5efe4ab8..0000000000 --- a/packages/web/web-fetch-http/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-web-fetch-http`. - * @module @deepseek-ai/dsh-web-fetch-http/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-web-fetch-http' - -/** Cordis companion plugin name. */ -export const name = 'web-fetch-http-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/web/web-fetch-http/tsconfig.json b/packages/web/web-fetch-http/tsconfig.json index a6599c60cd..c6fb75a5c1 100644 --- a/packages/web/web-fetch-http/tsconfig.json +++ b/packages/web/web-fetch-http/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../web" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 4aa1e5e255..b3d28d7c27 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-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/web/web-search-deepseek/README.md -README.md: dfa7a1d497372c5a42010ca76d1f9d226d296236 -README.zh.md: 7cc3bcf7306c884a5aea9f2d7974227abb25d257 +README.md: 123a266816502390b679a81795a938deb4b4e14c +README.zh.md: 168dadebea63c5f898b8e8163f265d59acc5f01e diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index dfa7a1d497..123a266816 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -91,7 +91,7 @@ The provider is built on two commitments: | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, Settings section installation, per-search option projection | | [`src/provider.ts`](src/provider.ts) | The `DeepSeekSearchProvider`: Messages request dispatch, block parsing, citation joining, credential resolution | | [`src/types.ts`](src/types.ts) | Anthropic wire types for the search response | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the service) | +| — | No runtime invariant companion is published; the package emits a pre-dispatch log event but owns no later authoritative dispatch event to relate it to. Exact envelope equality is pinned at the provider boundary instead. | ### Request flow diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 7cc3bcf730..168dadebea 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -91,7 +91,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、Settings 段安装、逐次选项投影 | | [`src/provider.ts`](src/provider.ts) | `DeepSeekSearchProvider`:Messages 请求分发、块解析、引用拼接、凭据解析 | | [`src/types.ts`](src/types.ts) | 搜索响应的 Anthropic 协议类型 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在服务处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在服务处强制执行。 | ### 请求流程 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 1f6ea441c9..37950b5bb6 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -18,16 +18,11 @@ "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", @@ -35,7 +30,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -49,7 +43,6 @@ "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts deleted file mode 100644 index 1f0b59d9e0..0000000000 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-deepseek`. - * @module @deepseek-ai/dsh-web-search-deepseek/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-web-search-deepseek' - -/** Cordis companion plugin name. */ -export const name = 'web-search-deepseek-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package emits a pre-dispatch log event but owns no - * later authoritative dispatch event to relate it to. Exact envelope equality - * is pinned at the provider boundary 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/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index a2e33ca5c5..f9e5787c60 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/web/web-search-exa/README.i18n.yaml b/packages/web/web-search-exa/README.i18n.yaml index a8d2a080ac..b131bb56a4 100644 --- a/packages/web/web-search-exa/README.i18n.yaml +++ b/packages/web/web-search-exa/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/web/web-search-exa/README.md -README.md: c1ce4db68314932d896e24163c0adca30c7ad3c6 -README.zh.md: 2bc12ad8d45f67cac28ce1c95ea8a7f65e4cc2db +README.md: 2bfa2a40adba5636abf599d2579ede961702c997 +README.zh.md: b33a989b936c0fbfa19692a59c5ab723a09b662b diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index c1ce4db683..2bfa2a40ad 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -84,7 +84,7 @@ The provider is a thin adapter over Exa's API with two deliberate rules: | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, environment fallback, provider registration | | [`src/provider.ts`](src/provider.ts) | The `ExaSearchProvider`: request dispatch, abort classification, result mapping | | [`src/types.ts`](src/types.ts) | Exa wire types: `ExaSearchResponse`, `ExaResult`, `ExaError` | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the service) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Request and mapping flow diff --git a/packages/web/web-search-exa/README.zh.md b/packages/web/web-search-exa/README.zh.md index 2bc12ad8d4..b33a989b93 100644 --- a/packages/web/web-search-exa/README.zh.md +++ b/packages/web/web-search-exa/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、环境变量回退、提供方注册 | | [`src/provider.ts`](src/provider.ts) | `ExaSearchProvider`:请求分发、中止分类、结果映射 | | [`src/types.ts`](src/types.ts) | Exa 协议类型:`ExaSearchResponse`、`ExaResult`、`ExaError` | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在服务处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在服务处强制执行。 | ### 请求与映射流程 diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index a189645134..7ad03c6412 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -18,22 +18,16 @@ "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-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -42,7 +36,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts deleted file mode 100644 index d7ba293ef1..0000000000 --- a/packages/web/web-search-exa/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-exa`. - * @module @deepseek-ai/dsh-web-search-exa/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-web-search-exa' - -/** Cordis companion plugin name. */ -export const name = 'web-search-exa-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index b1cb44f9cc..35a9cde789 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../web" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/web/web-search-perplexity/README.i18n.yaml b/packages/web/web-search-perplexity/README.i18n.yaml index 1f43ddd557..a63c82ba8e 100644 --- a/packages/web/web-search-perplexity/README.i18n.yaml +++ b/packages/web/web-search-perplexity/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/web/web-search-perplexity/README.md -README.md: a616925d172d7729b8cd24d5e701013111f5837d -README.zh.md: 48a866d04fc5458daf1424e7f2608e23a2984367 +README.md: 21754cea3aeb7e288a8fa085bc5561a25144d6c7 +README.zh.md: 8c078345a341d5fc394f86b57bc7369d9c999bfc diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index a616925d17..21754cea3a 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -84,7 +84,7 @@ The provider is a thin adapter over Perplexity's chat-completions endpoint with | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, environment fallback, provider registration | | [`src/provider.ts`](src/provider.ts) | The `PerplexitySearchProvider`: request dispatch, abort classification, answer and source mapping | | [`src/types.ts`](src/types.ts) | Perplexity wire types for the chat-completions response | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the service) | +| — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. | ### Request and mapping flow diff --git a/packages/web/web-search-perplexity/README.zh.md b/packages/web/web-search-perplexity/README.zh.md index 48a866d04f..8c078345a3 100644 --- a/packages/web/web-search-perplexity/README.zh.md +++ b/packages/web/web-search-perplexity/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、环境变量回退、提供方注册 | | [`src/provider.ts`](src/provider.ts) | `PerplexitySearchProvider`:请求分发、中止分类、答案与来源映射 | | [`src/types.ts`](src/types.ts) | chat-completions 响应的 Perplexity 协议类型 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在服务处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在服务处强制执行。 | ### 请求与映射流程 diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index ae3c485ef0..276acabbe5 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -18,22 +18,16 @@ "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-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -42,7 +36,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts deleted file mode 100644 index fa4d1981b6..0000000000 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-perplexity`. - * @module @deepseek-ai/dsh-web-search-perplexity/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-web-search-perplexity' - -/** Cordis companion plugin name. */ -export const name = 'web-search-perplexity-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -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/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index b1cb44f9cc..35a9cde789 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -22,9 +22,6 @@ }, { "path": "../web" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/web/web/README.i18n.yaml b/packages/web/web/README.i18n.yaml index a6ed2551fc..f92f5e4dc2 100644 --- a/packages/web/web/README.i18n.yaml +++ b/packages/web/web/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/web/web/README.md -README.md: 318fae3a6758fdb9ca8a6b39849ec35ba5901956 -README.zh.md: 34155c18dd46bc9dc20b677684f7567db0b1b93c +README.md: 331c5488ee13b1006314153046c42abb89c584e5 +README.zh.md: f4c25a047f66fce6ddca92c587a7dd03a0c26b07 diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 318fae3a67..331c5488ee 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -105,7 +105,7 @@ The package is built on one deliberate separation: |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the `WebRuntime` service, both provider registries, and execution-time selection | | [`src/types.ts`](src/types.ts) | Vocabulary: request/result types, the closed `WebFetchBody` union, and the `WebError` taxonomy | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the service) | +| — | No runtime invariant companion is published; provider maps are private and selection/result caps are enforced on each call; the seam publishes no independent registry or request/result observation stream. | ### Data model diff --git a/packages/web/web/README.zh.md b/packages/web/web/README.zh.md index 34155c18dd..f4c25a047f 100644 --- a/packages/web/web/README.zh.md +++ b/packages/web/web/README.zh.md @@ -105,7 +105,7 @@ const page = await ctx.web.fetch({ url: 'https://example.com' }) |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`WebRuntime` 服务、两个提供方注册表与执行时选择 | | [`src/types.ts`](src/types.ts) | 词汇:请求/结果类型、封闭的 `WebFetchBody` 联合与 `WebError` 分类体系 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在服务处强制执行) | +| — | 不发布运行时不变式伴生入口;约定在服务处强制执行。 | ### 数据模型 diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 03fc83a770..c1476cd965 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -18,21 +18,15 @@ "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/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -40,7 +34,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts deleted file mode 100644 index 4ec2462d13..0000000000 --- a/packages/web/web/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-web`. - * @module @deepseek-ai/dsh-web/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-web' - -/** Cordis companion plugin name. */ -export const name = 'web-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: provider maps are private and selection/result caps are enforced on each - * call; the seam publishes no independent registry or request/result observation stream. - */ -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/web/web/tsconfig.json b/packages/web/web/tsconfig.json index 601901c6f8..e9de391ba1 100644 --- a/packages/web/web/tsconfig.json +++ b/packages/web/web/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../llm/llm" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/webhook/webhook-github/README.i18n.yaml b/packages/webhook/webhook-github/README.i18n.yaml index 53fc099631..64524d22ec 100644 --- a/packages/webhook/webhook-github/README.i18n.yaml +++ b/packages/webhook/webhook-github/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/webhook/webhook-github/README.md -README.md: ce0e2da0aa355166ba940b9a991f6ee5c7fa1f9c -README.zh.md: c5ad924ec2040281d12c5890e5679413a07a4c81 +README.md: 9c2b20f4abbc183fd5aa78454ce3b2c9266aa79c +README.zh.md: 34d847b76acf4ead271f5005a924df6563aacb9f diff --git a/packages/webhook/webhook-github/README.md b/packages/webhook/webhook-github/README.md index ce0e2da0aa..9c2b20f4ab 100644 --- a/packages/webhook/webhook-github/README.md +++ b/packages/webhook/webhook-github/README.md @@ -84,3 +84,5 @@ Independent. Authentication and HTTP dispatch do not touch a model request; any None. + +**Runtime invariant:** No companion is published. Authentication and input validation occur at the exact HTTP operation; dsh-host-webserver owns route/disposer symmetry. diff --git a/packages/webhook/webhook-github/README.zh.md b/packages/webhook/webhook-github/README.zh.md index c5ad924ec2..34d847b76a 100644 --- a/packages/webhook/webhook-github/README.zh.md +++ b/packages/webhook/webhook-github/README.zh.md @@ -84,3 +84,5 @@ kind: "package-reference" 无。 + +**运行时不变式:** 不发布伴生入口。authentication 与 input validation 在对应 HTTP 操作中完成;route/disposer 对称性由 `dsh-host-webserver` 负责。 diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index c85ee5a031..9f9f8f303d 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -22,16 +22,11 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.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/**/*.js", "lib/types/**/*.d.ts" ], @@ -40,7 +35,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-webhook": "workspace:^" }, @@ -54,7 +48,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-webhook": "workspace:^" } diff --git a/packages/webhook/webhook-github/src/invariant.ts b/packages/webhook/webhook-github/src/invariant.ts deleted file mode 100644 index cd4e42bd98..0000000000 --- a/packages/webhook/webhook-github/src/invariant.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Package-owned invariant companion for the GitHub webhook adapter. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-webhook-github' - -/** Cordis invariant-companion plugin name. */ -export const name = 'webhook-github-invariant' -/** Registry required before reserving this package's invariant ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: authentication and input validation occur at the exact - * HTTP operation; dsh-host-webserver owns route/disposer symmetry. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's explained empty invariant. - * @param ctx - Cordis context carrying the invariant registry. - * @returns the invariant registration disposer. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/webhook/webhook-github/tests/invariant.spec.ts b/packages/webhook/webhook-github/tests/invariant.spec.ts deleted file mode 100644 index dece3e9db7..0000000000 --- a/packages/webhook/webhook-github/tests/invariant.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { describe, expect, it } from 'vitest' -import * as GitHubInvariant from '../src/invariant.ts' - -describe('GitHub webhook invariant companion', () => { - it('registers its explained empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry) - await expect(ctx.plugin(GitHubInvariant)).resolves.toBeDefined() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/webhook/webhook-github/tsconfig.json b/packages/webhook/webhook-github/tsconfig.json index 88d2fe7ac4..8c44266833 100644 --- a/packages/webhook/webhook-github/tsconfig.json +++ b/packages/webhook/webhook-github/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../webhook" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/workflow/tool-ralph/README.i18n.yaml b/packages/workflow/tool-ralph/README.i18n.yaml index 8cee45b949..ed1a814c10 100644 --- a/packages/workflow/tool-ralph/README.i18n.yaml +++ b/packages/workflow/tool-ralph/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/workflow/tool-ralph/README.md -README.md: 882201a2e0b1b105f4e57e3b24d2360ff4870370 -README.zh.md: edefe0ede50e2d5447d33d5b52d1f6b2f8ed3773 +README.md: dcdaa1abf0da735c821fb805f584a0e3660417b9 +README.zh.md: 1d5f42f7416c74424ed6c4b13750ef3e8ef030d2 diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md index 882201a2e0..dcdaa1abf0 100644 --- a/packages/workflow/tool-ralph/README.md +++ b/packages/workflow/tool-ralph/README.md @@ -81,7 +81,7 @@ The pending call is a `generic` card titled `ralph` with the immutable objective | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: fixed script, provider routing, report validation, tool registration | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; workflow and subagent owners validate the runs and children it starts) | +| — | No runtime invariant companion is published; this model-facing orchestration adapter owns no independent event stream; workflow and subagent owners validate the runs and child lifecycles it starts. | diff --git a/packages/workflow/tool-ralph/README.zh.md b/packages/workflow/tool-ralph/README.zh.md index edefe0ede5..1d5f42f741 100644 --- a/packages/workflow/tool-ralph/README.zh.md +++ b/packages/workflow/tool-ralph/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:固定脚本、提供方路由、报告校验、工具注册 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;工作流与 subagent 归属方校验它启动的运行与子 agent) | +| — | 不发布运行时不变式伴生入口;工作流与 subagent 归属方校验它启动的运行与子 agent。 | diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 4e5eb9ac03..db8c6ba3d7 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -18,22 +18,16 @@ "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-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -49,7 +43,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/workflow/tool-ralph/src/invariant.ts b/packages/workflow/tool-ralph/src/invariant.ts deleted file mode 100644 index 3b050136fb..0000000000 --- a/packages/workflow/tool-ralph/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-ralph`. - * @module @deepseek-ai/dsh-tool-ralph/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-tool-ralph' - -/** Cordis companion plugin name. */ -export const name = 'tool-ralph-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this model-facing orchestration adapter owns no independent event stream; - * workflow and subagent owners validate the runs and child lifecycles it starts. - */ -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/workflow/tool-ralph/tsconfig.json b/packages/workflow/tool-ralph/tsconfig.json index bcf92dc02d..294a851a5b 100644 --- a/packages/workflow/tool-ralph/tsconfig.json +++ b/packages/workflow/tool-ralph/tsconfig.json @@ -34,9 +34,6 @@ }, { "path": "../workflow" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/workflow/workflow-worker-thread/README.i18n.yaml b/packages/workflow/workflow-worker-thread/README.i18n.yaml index ded2a439cb..9135637976 100644 --- a/packages/workflow/workflow-worker-thread/README.i18n.yaml +++ b/packages/workflow/workflow-worker-thread/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/workflow/workflow-worker-thread/README.md -README.md: b17d93ad4487f4cbe0dbfc9ff69727a8fa9d9ed0 -README.zh.md: 11d9b02c4698185403b1244d826db57110b56a7f +README.md: 826a04bd9fe67ac722c63ae81affc0a7c90396d0 +README.zh.md: 56d7dda4cd26d9997d934e783a376c196ef689f0 diff --git a/packages/workflow/workflow-worker-thread/README.md b/packages/workflow/workflow-worker-thread/README.md index b17d93ad44..826a04bd9f 100644 --- a/packages/workflow/workflow-worker-thread/README.md +++ b/packages/workflow/workflow-worker-thread/README.md @@ -85,7 +85,7 @@ One worker thread per run keeps a misbehaving script from stalling the host and | [`src/protocol.ts`](src/protocol.ts) | Typed host/worker message protocol | | [`src/meta.ts`](src/meta.ts) | `meta` shape validation and normalization | | [`src/session.ts`](src/session.ts) | Child run projection and snapshotting before crossing to the worker | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; worker tests cover the boundary) | +| — | No runtime invariant companion is published; this process-boundary implementation exposes no same-process event relation; worker protocol and built-worker tests cover it. | ### Run sequence diff --git a/packages/workflow/workflow-worker-thread/README.zh.md b/packages/workflow/workflow-worker-thread/README.zh.md index 11d9b02c46..56d7dda4cd 100644 --- a/packages/workflow/workflow-worker-thread/README.zh.md +++ b/packages/workflow/workflow-worker-thread/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" | [`src/protocol.ts`](src/protocol.ts) | 带类型的宿主/worker 消息协议 | | [`src/meta.ts`](src/meta.ts) | `meta` 形状校验与规范化 | | [`src/session.ts`](src/session.ts) | 子 agent 运行在跨入 worker 前的投影与快照 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;worker 测试覆盖该边界) | +| — | 不发布运行时不变式伴生入口;worker 测试覆盖该边界。 | ### 运行顺序 diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 328ae143b7..dc53184ed6 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, "./worker": { "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" @@ -31,7 +27,6 @@ }, "files": [ "lib/index.js", - "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts" ], @@ -39,7 +34,6 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/workflow/workflow-worker-thread/src/invariant.ts b/packages/workflow/workflow-worker-thread/src/invariant.ts deleted file mode 100644 index 677c0e52a9..0000000000 --- a/packages/workflow/workflow-worker-thread/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-workflow-worker-thread`. - * @module @deepseek-ai/dsh-workflow-worker-thread/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-workflow-worker-thread' - -/** Cordis companion plugin name. */ -export const name = 'workflow-worker-thread-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this process-boundary implementation exposes no same-process event relation; - * worker protocol and built-worker tests cover it. - */ -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/workflow/workflow-worker-thread/tsconfig.json b/packages/workflow/workflow-worker-thread/tsconfig.json index 4111e22af8..730a3e61d9 100644 --- a/packages/workflow/workflow-worker-thread/tsconfig.json +++ b/packages/workflow/workflow-worker-thread/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../workflow" - }, - { - "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/workflow/workflow-worker-thread/tsdown.config.ts b/packages/workflow/workflow-worker-thread/tsdown.config.ts index 962a3d9078..8ebd93d89f 100644 --- a/packages/workflow/workflow-worker-thread/tsdown.config.ts +++ b/packages/workflow/workflow-worker-thread/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js', 'lib/types/invariant.js'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790f3350b7..e8bdaba661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,9 +591,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -649,9 +646,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry @@ -710,9 +704,6 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -777,9 +768,6 @@ importers: '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../../jobs/jobs @@ -862,9 +850,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command @@ -899,9 +884,6 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../../host/directory-picker - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -923,9 +905,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/attachment/attachment-local: dependencies: @@ -945,9 +924,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/boot/app-boot: dependencies: @@ -982,9 +958,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -1006,9 +979,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants commander: specifier: ^15.0.0 version: 15.0.0 @@ -1031,9 +1001,6 @@ importers: '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/bundle/base: dependencies: @@ -1296,9 +1263,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/bundle/headless: dependencies: @@ -1327,9 +1291,6 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1358,9 +1319,6 @@ importers: '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/bundle/sdk-minimal: dependencies: @@ -1422,9 +1380,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/bundle/web-app: dependencies: @@ -1645,9 +1600,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-shell-env': specifier: workspace:^ version: link:../../shell/shell-env @@ -1685,9 +1637,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1756,9 +1705,6 @@ importers: '@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-settings': specifier: workspace:^ version: link:../../settings/settings @@ -1796,9 +1742,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/client/ui-agent-preset: devDependencies: @@ -1847,9 +1790,6 @@ importers: '@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 @@ -1889,9 +1829,6 @@ importers: '@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 @@ -1938,9 +1875,6 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../ui-trajectory - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -1971,9 +1905,6 @@ importers: '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../ui-sidebar - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@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) @@ -2050,9 +1981,6 @@ importers: '@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 @@ -2132,9 +2060,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2223,9 +2148,6 @@ importers: '@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 @@ -2313,9 +2235,6 @@ importers: '@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 @@ -2359,9 +2278,6 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@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) @@ -2386,9 +2302,6 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@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) @@ -2443,9 +2356,6 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2504,9 +2414,6 @@ importers: '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2546,9 +2453,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2579,9 +2483,6 @@ importers: '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2624,9 +2525,6 @@ importers: '@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-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback @@ -2697,9 +2595,6 @@ importers: '@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 @@ -2754,9 +2649,6 @@ importers: '@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-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets @@ -2796,9 +2688,6 @@ importers: '@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-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode @@ -2878,9 +2767,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2917,9 +2803,6 @@ importers: '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference @@ -2981,9 +2864,6 @@ importers: '@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 @@ -3018,9 +2898,6 @@ importers: '@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-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3076,9 +2953,6 @@ importers: '@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-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3118,9 +2992,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3154,9 +3025,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@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) @@ -3206,9 +3074,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3252,9 +3117,6 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3297,9 +3159,6 @@ importers: '@deepseek-ai/dsh-client-ui-tool': specifier: workspace:^ version: link:../ui-tool - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3324,9 +3183,6 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3366,9 +3222,6 @@ importers: '@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 @@ -3433,9 +3286,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3488,9 +3338,6 @@ importers: '@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-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path @@ -3558,9 +3405,6 @@ importers: '@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 @@ -3625,9 +3469,6 @@ importers: '@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 @@ -3685,9 +3526,6 @@ importers: '@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 @@ -3752,9 +3590,6 @@ importers: '@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 @@ -3794,9 +3629,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3818,18 +3650,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/code-runtime/code-runtime-python: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/code-runtime/code-runtime-worker-thread: dependencies: @@ -3843,9 +3669,6 @@ importers: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../code-runtime - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3873,9 +3696,6 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../compaction - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4020,9 +3840,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4053,9 +3870,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/context/file-reference-local: dependencies: @@ -4072,9 +3886,6 @@ importers: '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../file-reference - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4100,9 +3911,6 @@ importers: '@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 @@ -4204,9 +4012,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4265,9 +4070,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4336,9 +4138,6 @@ importers: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope @@ -4492,9 +4291,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -4523,9 +4319,6 @@ importers: '@deepseek-ai/dsh-fs-e2b': specifier: workspace:^ version: link:../fs-e2b - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../test-support/loader-smoke @@ -4565,9 +4358,6 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/e2b/subprocess-e2b: dependencies: @@ -4581,9 +4371,6 @@ importers: '@deepseek-ai/dsh-e2b': specifier: workspace:^ version: link:../e2b - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess @@ -4782,9 +4569,6 @@ importers: '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants js-yaml: specifier: ^4.2.0 version: 4.3.1 @@ -4801,9 +4585,6 @@ importers: '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants js-yaml: specifier: ^4.2.0 version: 4.3.1 @@ -4847,9 +4628,6 @@ importers: '@deepseek-ai/dsh-experimental-agent-team': specifier: workspace:^ version: link:../agent-team - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -4896,9 +4674,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -4930,9 +4705,6 @@ importers: '@deepseek-ai/dsh-experimental-agent-team': specifier: workspace:^ version: link:../agent-team - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -4988,9 +4760,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -5043,9 +4812,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -5103,9 +4869,6 @@ importers: '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -5134,9 +4897,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5167,9 +4927,6 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../cordis-host-runner - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5224,9 +4981,6 @@ importers: '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:^ version: link:../cordis-client-runner - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -5257,9 +5011,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5291,9 +5042,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5352,9 +5100,6 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5367,9 +5112,6 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5385,9 +5127,6 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -5431,9 +5170,6 @@ importers: '@deepseek-ai/dsh-fs-observation-policy': specifier: workspace:^ version: link:../fs-observation-policy - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5477,9 +5213,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5532,9 +5265,6 @@ importers: '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../fs-sandbox - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5574,9 +5304,6 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5699,9 +5426,6 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5736,9 +5460,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5757,9 +5478,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5809,9 +5527,6 @@ importers: '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../hook-protocol - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5864,9 +5579,6 @@ importers: '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../hook-protocol - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -5897,9 +5609,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/host/directory-picker-auto: devDependencies: @@ -5930,9 +5639,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/host/directory-picker-browse: dependencies: @@ -5946,9 +5652,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/host/directory-picker-native: dependencies: @@ -5965,9 +5668,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants tsx: specifier: ^4.19.2 version: 4.22.4 @@ -5993,9 +5693,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/host/plugin-inventory: dependencies: @@ -6012,9 +5709,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -6034,9 +5728,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@types/compression': specifier: ^1.8.1 version: 1.8.1 @@ -6055,9 +5746,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/interaction/commands: dependencies: @@ -6144,9 +5832,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6199,9 +5884,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6248,9 +5930,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../jobs @@ -6276,9 +5955,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../jobs @@ -6306,9 +5982,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/llm/llm: dependencies: @@ -6380,9 +6053,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -6429,9 +6099,6 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -6536,9 +6203,6 @@ importers: '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../deepseek-llm-api-extensions - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -6564,9 +6228,6 @@ importers: '@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 @@ -6588,9 +6249,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6613,9 +6271,6 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6653,9 +6308,6 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6705,9 +6357,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6849,9 +6498,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -6874,9 +6520,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6899,9 +6542,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6955,9 +6595,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../shell/pwsh-local @@ -7016,9 +6653,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7034,9 +6668,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7068,9 +6699,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7147,9 +6775,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7174,9 +6799,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7211,9 +6833,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7245,9 +6864,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7302,9 +6918,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7354,9 +6967,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -7379,9 +6989,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7404,9 +7011,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7429,9 +7033,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7448,9 +7049,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7482,9 +7080,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7503,9 +7098,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7558,9 +7150,6 @@ importers: '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7635,9 +7224,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7672,9 +7258,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7703,9 +7286,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7759,9 +7339,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../settings @@ -7775,9 +7352,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -7802,9 +7376,6 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -7836,9 +7407,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -7860,9 +7428,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../pwsh-local @@ -7890,9 +7455,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -7918,9 +7480,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7955,9 +7514,6 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../../jobs/jobs @@ -8022,9 +7578,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8077,9 +7630,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../../jobs/jobs @@ -8144,9 +7694,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8193,9 +7740,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8208,9 +7752,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -8236,9 +7777,6 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -8255,9 +7793,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8285,9 +7820,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8313,9 +7845,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8341,9 +7870,6 @@ importers: '@deepseek-ai/dsh-code-runtime-worker-thread': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker-thread - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8365,9 +7891,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/storage/storage-domain: dependencies: @@ -8397,9 +7920,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage @@ -8413,9 +7933,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage @@ -8517,9 +8034,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8584,9 +8098,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8654,9 +8165,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8718,9 +8226,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8997,9 +8502,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9046,9 +8548,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9085,9 +8584,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/subprocess/subprocess-local: dependencies: @@ -9101,9 +8597,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../test-support/loader-smoke @@ -9123,9 +8616,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/terminal/terminal: devDependencies: @@ -9138,9 +8628,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -9166,9 +8653,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -9206,9 +8690,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../../jobs/jobs @@ -9263,9 +8744,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9327,9 +8805,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -9354,9 +8829,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/test-support/llm-replay: devDependencies: @@ -9369,9 +8841,6 @@ importers: '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../../llm/deepseek-llm-api-extensions - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9397,9 +8866,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9437,9 +8903,6 @@ importers: '@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 @@ -9526,9 +8989,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../extensions/tool-cordis @@ -9551,9 +9011,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../registry @@ -9566,9 +9023,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/typert/registry: dependencies: @@ -9582,90 +9036,60 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/atomic-write: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/brand: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/crypto: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/home-paths: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/launch-environment: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/native-command: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/output-retention: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/timeout: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/util/workspace-path: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants packages/web/tool-web: dependencies: @@ -9685,9 +9109,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9731,9 +9152,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9753,9 +9171,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -9781,9 +9196,6 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -9806,9 +9218,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -9825,9 +9234,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -9901,9 +9307,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -9932,9 +9335,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index c0d13d2366..89a177b694 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -83,7 +83,6 @@ describe('package payload constraints', () => { dsh: { bundle: { patch: './cordis.patch.yml' } }, })).toEqual([ 'lib/index.js', - 'lib/invariant.js', 'cordis.patch.yml', 'lib/types/**/*.d.ts', ]) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e76c248aa7..0318bc321c 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -187,9 +187,9 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ] return [ 'lib/index.js', - // Every package publishes its invariant ownership companion as a separate - // bundle; the package-invariant gate validates the companion itself. - 'lib/invariant.js', + // Packages with an invariant export publish its runtime as a separate + // bundle; the package-invariant gate validates the source/export pairing. + ...manifest.exports?.['./invariant'] ? ['lib/invariant.js'] : [], ...manifest.bin ? ['lib/bin.js'] : [], // Worker-thread packages ship a CJS worker entry; the browser worker // bundle is an ES module a page loads with `new Worker(type: 'module')`. diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 626aa59bce..2ee5475c04 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { collectPackageInvariantViolations, + packageInvariantOwners, } from './package-invariants.ts' const roots: string[] = [] @@ -27,42 +28,57 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo } function fixture(options: { + companion?: boolean packageName?: string source?: string invariantExport?: boolean + invariantFile?: boolean invariantDependency?: boolean invariantReference?: boolean buildEntry?: boolean + omissionReason?: boolean } = {}): string { const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-')) roots.push(root) const dir = join(root, 'packages/core/probe') mkdirSync(join(dir, 'src'), { recursive: true }) const packageName = options.packageName ?? '@deepseek-ai/dsh-probe' + const companion = options.companion ?? true + const invariantExport = options.invariantExport ?? companion + const invariantFile = options.invariantFile ?? companion + const invariantDependency = options.invariantDependency ?? companion + const invariantReference = options.invariantReference ?? companion + const buildEntry = options.buildEntry ?? companion const manifest = { name: packageName, - exports: options.invariantExport === false ? {} : { + exports: invariantExport ? { './invariant': { types: './lib/types/invariant.d.ts', default: './lib/invariant.js', }, - }, - files: ['lib/index.js', 'lib/invariant.js'], - peerDependencies: options.invariantDependency === false ? {} : { + } : {}, + files: ['lib/index.js', ...invariantFile ? ['lib/invariant.js'] : []], + peerDependencies: !invariantDependency ? {} : { '@deepseek-ai/dsh-invariants': 'workspace:^', }, - devDependencies: options.invariantDependency === false ? {} : { + devDependencies: !invariantDependency ? {} : { '@deepseek-ai/dsh-invariants': 'workspace:^', }, } writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ - references: options.invariantReference === false ? [] : [{ path: '../../runtime-diagnostics/invariants' }], + references: invariantReference ? [{ path: '../../runtime-diagnostics/invariants' }] : [], }, null, 2)}\n`) - writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName)) + if (companion) { + writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName)) + } writeFileSync( join(dir, 'tsdown.config.ts'), - options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n", + buildEntry ? "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n" : "export default { entry: ['lib/types/index.js'] }\n", + ) + writeFileSync( + join(dir, 'README.md'), + options.omissionReason === false ? '# Probe\n' : '# Probe\n\nNo runtime invariant companion is published because this fixture owns no diverging observations.\n', ) return root } @@ -72,6 +88,23 @@ describe('package invariant gate', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) + it('accepts a package that cleanly omits an invariant companion', () => { + const root = fixture({ companion: false }) + expect(collectPackageInvariantViolations(root)).toEqual([]) + expect(packageInvariantOwners(root)).toEqual([]) + }) + + it('requires an omitted companion to have a package-specific README reason', () => { + const violations = collectPackageInvariantViolations(fixture({ + companion: false, + omissionReason: false, + })) + expect(violations).toContainEqual({ + path: 'packages/core/probe/README.md', + message: 'omitted companion requires a package-specific "No ... companion is published" reason', + }) + }) + it('accepts an invariant reference owned by a package-local leaf project', () => { const root = fixture({ invariantReference: false }) const dir = join(root, 'packages/core/probe') @@ -89,6 +122,7 @@ describe('package invariant gate', () => { it('rejects missing publication metadata and build output', () => { const violations = collectPackageInvariantViolations(fixture({ invariantExport: false, + invariantFile: false, invariantDependency: false, invariantReference: false, buildEntry: false, @@ -102,6 +136,20 @@ describe('package invariant gate', () => { ])) }) + it('rejects publication and build wiring left behind after omission', () => { + const violations = collectPackageInvariantViolations(fixture({ + companion: false, + invariantExport: true, + invariantFile: true, + buildEntry: true, + })) + expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([ + expect.stringContaining('exports["./invariant"] must be omitted'), + expect.stringContaining('files must omit lib/invariant.js'), + expect.stringContaining('build override must omit lib/types/invariant.js'), + ])) + }) + it('rejects foreign, duplicate, and unresolved registrations', () => { const source = ` export const name = 'probe-invariant' @@ -175,8 +223,8 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => .toContain('must not default-export; Loader must retain the companion namespace') }) - it('accepts explained empty installers and rejects unexplained ones', () => { - const explained = ` + it('rejects empty installers because packages without a check omit the companion', () => { + const source = ` export const name = 'probe-invariant' export const inject = ['invariants'] const PACKAGE_NAME = '@deepseek-ai/dsh-probe' @@ -185,17 +233,7 @@ const install = () => {} export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => ctx.invariants.register(PACKAGE_NAME, install) ` - expect(collectPackageInvariantViolations(fixture({ source: explained }))).toEqual([]) - - const unexplained = ` -export const name = 'probe-invariant' -export const inject = ['invariants'] -const PACKAGE_NAME = '@deepseek-ai/dsh-probe' -const install = () => {} -export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => - ctx.invariants.register(PACKAGE_NAME, install) -` - expect(collectPackageInvariantViolations(fixture({ source: unexplained })).map(violation => violation.message)) - .toContain('empty install function must explain why with a "No runtime invariant:" comment') + expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + .toContain('empty install function is unnecessary; omit the companion and its publication wiring') }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 3e4e5ac757..c19ca1dc10 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -1,19 +1,19 @@ /** * Package-invariant companion discovery and structural checks. - * The runtime registry stays product-independent; this gate makes ownership - * exhaustive across packages without centralizing package checks. + * The runtime registry stays product-independent; this gate keeps each + * published companion complete without requiring synthetic empty companions. */ import { existsSync, globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' -/** Required explanation marker for an intentionally empty installer. */ -const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' +/** Package README sentence that records why an invariant companion is omitted. */ +const OMITTED_COMPANION_REASON = /No (?:(?:runtime )?invariant )?companion is published(?: because|[.:;—])\s+\S/i interface PackageManifest { name?: string - exports?: Record + exports?: Record files?: string[] peerDependencies?: Record devDependencies?: Record @@ -33,8 +33,14 @@ export interface PackageInvariantViolation { readonly message: string } -/** Discover every package under the repository package tree. */ +/** Discover packages that own an invariant companion. */ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { + return packageInvariantPackages(root) + .filter(owner => existsSync(resolve(root, owner.sourcePath))) +} + +/** Discover every package under the repository package tree. */ +function packageInvariantPackages(root: string): PackageInvariantOwner[] { return globSync('packages/*/*/package.json', { cwd: root }) .map(path => path.split(sep).join('/')) .sort() @@ -56,11 +62,16 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { /** Return all violations of the package-invariant companion rules. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] - for (const owner of packageInvariantOwners(root)) { + for (const owner of packageInvariantPackages(root)) { const manifest = readManifest(resolve(root, owner.manifestPath)) - checkManifest(owner, manifest, violations) - checkBuild(owner, root, violations) - checkSource(owner, root, violations) + const hasCompanion = existsSync(resolve(root, owner.sourcePath)) + checkManifest(owner, manifest, hasCompanion, violations) + checkBuild(owner, root, hasCompanion, violations) + if (hasCompanion) { + checkSource(owner, root, violations) + } else { + checkOmissionReason(owner, root, violations) + } } return violations } @@ -80,10 +91,29 @@ function addViolation( function checkManifest( owner: PackageInvariantOwner, manifest: PackageManifest, + hasCompanion: boolean, violations: PackageInvariantViolation[], ): void { const invariantExport = manifest.exports?.['./invariant'] + if (!hasCompanion) { + if (invariantExport !== undefined) { + addViolation( + violations, + owner.manifestPath, + 'exports["./invariant"] must be omitted when src/invariant.ts is absent', + ) + } + if (manifest.files?.includes('lib/invariant.js')) { + addViolation( + violations, + owner.manifestPath, + 'files must omit lib/invariant.js when src/invariant.ts is absent', + ) + } + return + } if (typeof invariantExport !== 'object' + || invariantExport === null || invariantExport.types !== './lib/types/invariant.d.ts' || invariantExport.default !== './lib/invariant.js') { addViolation( @@ -115,10 +145,12 @@ function checkManifest( function checkBuild( owner: PackageInvariantOwner, root: string, + hasCompanion: boolean, violations: PackageInvariantViolation[], ): void { const tsconfigPath = `${owner.dir}/tsconfig.json` - if (owner.packageName !== '@deepseek-ai/dsh-invariants' + if (hasCompanion + && owner.packageName !== '@deepseek-ai/dsh-invariants' && !projectReferencesInvariants(root, owner.dir, tsconfigPath)) { addViolation( violations, @@ -130,8 +162,31 @@ function checkBuild( const configPath = `${owner.dir}/tsdown.config.ts` if (!existsSync(resolve(root, configPath))) return const source = readFileSync(resolve(root, configPath), 'utf8') - if (!source.includes('lib/types/invariant.js')) { + const bundlesCompanion = source.includes('lib/types/invariant.js') + if (hasCompanion && !bundlesCompanion) { addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js') + } else if (!hasCompanion && bundlesCompanion) { + addViolation( + violations, + configPath, + 'package build override must omit lib/types/invariant.js when src/invariant.ts is absent', + ) + } +} + +function checkOmissionReason( + owner: PackageInvariantOwner, + root: string, + violations: PackageInvariantViolation[], +): void { + const readmePath = `${owner.dir}/README.md` + const absolutePath = resolve(root, readmePath) + if (!existsSync(absolutePath) || !OMITTED_COMPANION_REASON.test(readFileSync(absolutePath, 'utf8'))) { + addViolation( + violations, + readmePath, + 'omitted companion requires a package-specific "No ... companion is published" reason', + ) } } @@ -166,10 +221,6 @@ function checkSource( violations: PackageInvariantViolation[], ): void { const absolutePath = resolve(root, owner.sourcePath) - if (!existsSync(absolutePath)) { - addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') - return - } const sourceText = readFileSync(absolutePath, 'utf8') if (sourceText.includes('@generated')) { addViolation( @@ -235,17 +286,15 @@ function checkSource( if (hasDefaultExport(sourceFile)) { addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace') } - checkInstaller(owner, sourceFile, sourceText, violations) + checkInstaller(owner, sourceFile, violations) } function checkInstaller( owner: PackageInvariantOwner, sourceFile: ts.SourceFile, - sourceText: string, violations: PackageInvariantViolation[], ): void { let initializer: ts.Expression | undefined - let declarationStatement: ts.VariableStatement | undefined for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { @@ -253,7 +302,6 @@ function checkInstaller( && declaration.name.text === 'install' && declaration.initializer !== undefined) { initializer = declaration.initializer - declarationStatement = statement } } } @@ -263,16 +311,11 @@ function checkInstaller( return } if (ts.isBlock(installer.body) && installer.body.statements.length === 0) { - const declarationText = declarationStatement === undefined - ? '' - : sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd()) - if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) { - addViolation( - violations, - owner.sourcePath, - `empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`, - ) - } + addViolation( + violations, + owner.sourcePath, + 'empty install function is unnecessary; omit the companion and its publication wiring', + ) return } const reporter = installer.parameters[1]?.name diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 93e12652ba..ccde826e7e 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -95,7 +95,7 @@ async function withDelayedFirstCompanion( } describe('global test invariant host', () => { - it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { + it('uses one topology to reserve every companion owner with enabled checks', async () => { const ctx = new Context() await ctx.plugin(TestInvariantProbe) @@ -117,9 +117,10 @@ describe('global test invariant host', () => { expect(unreserved).toEqual([]) }) - it('mounts the owning package companion while leaving non-package roots service-only', () => { + it('mounts an owning companion and leaves omitted or non-package roots service-only', () => { expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts')) .toEqual(['../packages/core/tools/src/invariant.ts']) + expect(testInvariantCompanionPaths('/repo/packages/util/brand/tests/brand.spec.ts')).toEqual([]) expect(testInvariantCompanionPaths('/repo/apps/cli/tests/profiles/headless/example.spec.ts')).toEqual([]) expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts')) .toEqual(Object.keys(testInvariantCompanions).sort()) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a3b96a90a3..b21aabd637 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -1,6 +1,7 @@ /** * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant - * service with global enablement plus the current test package's companion. + * service with global enablement plus the current test package's companion, + * when it publishes one. * One topology test mounts every companion; focused invariant tests own their * service topology explicitly. */ @@ -36,10 +37,10 @@ export interface TestInvariantCompanion { export const TEST_INVARIANT_READY_SERVICE = 'testInvariantReady' /** - * Every package companion as a lazy loader keyed by glob path. Ordinary tests + * Every published package companion as a lazy loader keyed by glob path. Ordinary tests * load only their owner's module; the exhaustive topology test loads and * executes all of them, so aggregated coverage still observes every - * registration while per-file setup stops importing 168 companions and their + * registration while per-file setup avoids importing unrelated companions and their * transitive package sources. */ export const testInvariantCompanions: Readonly Promise>> = @@ -137,7 +138,7 @@ class TestAttachmentStore extends AttachmentStore { /** * Select the package companions that an ordinary test root must register. * Package tests receive their owner's checks; the dedicated topology test - * receives every owner so coverage and exhaustive runtime registration remain + * receives every companion owner so coverage and runtime registration remain * independently enforced. * @param testPath - absolute or repo-relative normalized Vitest file path. * @returns sorted `import.meta.glob` keys for companions to mount. @@ -150,10 +151,7 @@ export function testInvariantCompanionPaths(testPath: string): string[] { const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//) if (owner === null) return [] const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts` - if (testInvariantCompanions[companionPath] === undefined) { - throw new Error(`test invariants: package test has no companion at ${companionPath}`) - } - return [companionPath] + return testInvariantCompanions[companionPath] === undefined ? [] : [companionPath] } function startInvariantHost(root: Context): InvariantHost { diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 4c0034dce5..b4a37cdc25 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -25,6 +25,7 @@ const loaderUrl = options['loader-url'] ?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort() +let companionCount = 0 const { default: Loader } = await import(loaderUrl) const loader = Object.create(Loader.prototype) @@ -37,7 +38,10 @@ for (const manifestPath of manifests) { continue } const invariantExport = manifest.exports?.['./invariant'] + if (invariantExport === undefined) continue + companionCount += 1 if (typeof invariantExport !== 'object' + || invariantExport === null || invariantExport.default !== './lib/invariant.js' || !manifest.files?.includes('lib/invariant.js')) { failures.push(`${packageName}: manifest does not publish ./lib/invariant.js as ./invariant`) @@ -79,7 +83,7 @@ if (failures.length > 0) { process.exit(1) } -console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) +console.log(`verify-built-package-invariants: ${companionCount} compiled companion(s) passed plain-Node Loader checks.`) function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) { for (const pattern of files) { diff --git a/scripts/verify-built-package-invariants.spec.ts b/scripts/verify-built-package-invariants.spec.ts index 4863e30c54..b01fd45628 100644 --- a/scripts/verify-built-package-invariants.spec.ts +++ b/scripts/verify-built-package-invariants.spec.ts @@ -13,6 +13,7 @@ afterEach(() => { }) function fixture(options: { + companion?: boolean invariantSource?: string invariantExport?: string runtimeChunk?: string @@ -21,20 +22,23 @@ function fixture(options: { roots.push(root) const packageDir = join(root, 'packages/core/probe') mkdirSync(join(packageDir, 'lib'), { recursive: true }) + const companion = options.companion ?? true writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh-probe', type: 'module', - files: ['lib/invariant.js'], - exports: { + files: companion ? ['lib/invariant.js'] : [], + exports: companion ? { './invariant': { default: options.invariantExport ?? './lib/invariant.js', }, - }, + } : {}, }, null, 2)}\n`) - writeFileSync( - join(packageDir, 'lib/invariant.js'), - options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n", - ) + if (companion) { + writeFileSync( + join(packageDir, 'lib/invariant.js'), + options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n", + ) + } if (options.runtimeChunk !== undefined) { writeFileSync(join(packageDir, 'lib/chunk.js'), options.runtimeChunk) } @@ -62,6 +66,13 @@ describe('built package invariant verifier', () => { expect(result.stdout).toContain('1 compiled companion(s) passed plain-Node Loader checks') }) + it('accepts packages that do not publish a companion', () => { + const { root, loaderUrl } = fixture({ companion: false }) + const result = verify(root, loaderUrl) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('0 compiled companion(s) passed plain-Node Loader checks') + }) + it('rejects a default export and a broken invariant export map', () => { const withDefault = fixture({ invariantSource: "export default {}\nexport const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n", diff --git a/tsconfig.base.json b/tsconfig.base.json index 661a546bf8..92f21f2c1b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -233,306 +233,180 @@ "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], - "@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], - "@deepseek-ai/dsh-acp-app/invariant": ["./packages/bundle/acp-app/src/invariant.ts"], "@deepseek-ai/dsh-agent": ["./packages/core/agent/src"], "@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"], - "@deepseek-ai/dsh-agent-default-model/invariant": ["./packages/core/agent-default-model/src/invariant.ts"], "@deepseek-ai/dsh-agent-instructions": ["./packages/context/agent-instructions/src"], - "@deepseek-ai/dsh-agent-instructions/invariant": ["./packages/context/agent-instructions/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop": ["./packages/core/agent-loop/src"], "@deepseek-ai/dsh-agent-loop-testkit": ["./packages/test-support/agent-loop-testkit/src"], - "@deepseek-ai/dsh-agent-loop-testkit/invariant": ["./packages/test-support/agent-loop-testkit/src/invariant.ts"], "@deepseek-ai/dsh-agent-presets": ["./packages/preset/agent-presets/src"], "@deepseek-ai/dsh-agent-presets/invariant": ["./packages/preset/agent-presets/src/invariant.ts"], "@deepseek-ai/dsh-agent-spine-demo": ["./packages/examples/agent-spine-demo/src"], - "@deepseek-ai/dsh-agent-spine-demo/invariant": ["./packages/examples/agent-spine-demo/src/invariant.ts"], "@deepseek-ai/dsh-agent-tool-presentation": ["./packages/core/agent-tool-presentation/src"], - "@deepseek-ai/dsh-agent-tool-presentation/invariant": ["./packages/core/agent-tool-presentation/src/invariant.ts"], "@deepseek-ai/dsh-anonymous-user-id": ["./packages/identity/anonymous-user-id/src"], - "@deepseek-ai/dsh-anonymous-user-id/invariant": ["./packages/identity/anonymous-user-id/src/invariant.ts"], "@deepseek-ai/dsh-app-boot": ["./packages/boot/app-boot/src"], - "@deepseek-ai/dsh-app-boot/invariant": ["./packages/boot/app-boot/src/invariant.ts"], "@deepseek-ai/dsh-atomic-write": ["./packages/util/atomic-write/src"], - "@deepseek-ai/dsh-atomic-write/invariant": ["./packages/util/atomic-write/src/invariant.ts"], "@deepseek-ai/dsh-attachment": ["./packages/attachment/attachment/src"], - "@deepseek-ai/dsh-attachment/invariant": ["./packages/attachment/attachment/src/invariant.ts"], "@deepseek-ai/dsh-attachment-local": ["./packages/attachment/attachment-local/src"], - "@deepseek-ai/dsh-attachment-local/invariant": ["./packages/attachment/attachment-local/src/invariant.ts"], "@deepseek-ai/dsh-authorization": ["./packages/credentials/authorization/src"], "@deepseek-ai/dsh-authorization/invariant": ["./packages/credentials/authorization/src/invariant.ts"], "@deepseek-ai/dsh-base": ["./packages/bundle/base/src"], - "@deepseek-ai/dsh-base/invariant": ["./packages/bundle/base/src/invariant.ts"], "@deepseek-ai/dsh-bash-local": ["./packages/shell/bash-local/src"], - "@deepseek-ai/dsh-bash-local/invariant": ["./packages/shell/bash-local/src/invariant.ts"], "@deepseek-ai/dsh-bash-sandbox": ["./packages/shell/bash-sandbox/src"], - "@deepseek-ai/dsh-bash-sandbox/invariant": ["./packages/shell/bash-sandbox/src/invariant.ts"], "@deepseek-ai/dsh-brand": ["./packages/util/brand/src"], - "@deepseek-ai/dsh-brand/invariant": ["./packages/util/brand/src/invariant.ts"], "@deepseek-ai/dsh-cmdline": ["./packages/boot/cmdline/src"], - "@deepseek-ai/dsh-cmdline/invariant": ["./packages/boot/cmdline/src/invariant.ts"], "@deepseek-ai/dsh-code-runtime": ["./packages/code-runtime/code-runtime/src"], - "@deepseek-ai/dsh-code-runtime/invariant": ["./packages/code-runtime/code-runtime/src/invariant.ts"], "@deepseek-ai/dsh-code-runtime-python": ["./packages/code-runtime/code-runtime-python/src"], - "@deepseek-ai/dsh-code-runtime-python/invariant": ["./packages/code-runtime/code-runtime-python/src/invariant.ts"], "@deepseek-ai/dsh-code-runtime-worker-thread": ["./packages/code-runtime/code-runtime-worker-thread/src"], - "@deepseek-ai/dsh-code-runtime-worker-thread/invariant": ["./packages/code-runtime/code-runtime-worker-thread/src/invariant.ts"], "@deepseek-ai/dsh-command-compact": ["./packages/compaction/command-compact/src"], - "@deepseek-ai/dsh-command-compact/invariant": ["./packages/compaction/command-compact/src/invariant.ts"], "@deepseek-ai/dsh-command-feedback": ["./packages/feedback/command-feedback/src"], - "@deepseek-ai/dsh-command-feedback/invariant": ["./packages/feedback/command-feedback/src/invariant.ts"], "@deepseek-ai/dsh-command-goal": ["./packages/goal/command-goal/src"], - "@deepseek-ai/dsh-command-goal/invariant": ["./packages/goal/command-goal/src/invariant.ts"], "@deepseek-ai/dsh-commands": ["./packages/interaction/commands/src"], "@deepseek-ai/dsh-commands/invariant": ["./packages/interaction/commands/src/invariant.ts"], "@deepseek-ai/dsh-compaction": ["./packages/compaction/compaction/src"], "@deepseek-ai/dsh-compaction/invariant": ["./packages/compaction/compaction/src/invariant.ts"], "@deepseek-ai/dsh-compaction-basic": ["./packages/compaction/compaction-basic/src"], - "@deepseek-ai/dsh-compaction-basic/invariant": ["./packages/compaction/compaction-basic/src/invariant.ts"], "@deepseek-ai/dsh-compaction-tool-result-pruner": ["./packages/compaction/compaction-tool-result-pruner/src"], - "@deepseek-ai/dsh-compaction-tool-result-pruner/invariant": ["./packages/compaction/compaction-tool-result-pruner/src/invariant.ts"], - "@deepseek-ai/dsh-cordis-host-runner/invariant": ["./packages/extensions/cordis-host-runner/src/invariant.ts"], "@deepseek-ai/dsh-credentials": ["./packages/credentials/credentials/src"], "@deepseek-ai/dsh-credentials/invariant": ["./packages/credentials/credentials/src/invariant.ts"], "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], - "@deepseek-ai/dsh-credentials-local/invariant": ["./packages/credentials/credentials-local/src/invariant.ts"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], - "@deepseek-ai/dsh-deepseek-llm-api-extensions/invariant": ["./packages/llm/deepseek-llm-api-extensions/src/invariant.ts"], "@deepseek-ai/dsh-e2b": ["./packages/e2b/e2b/src"], - "@deepseek-ai/dsh-e2b/invariant": ["./packages/e2b/e2b/src/invariant.ts"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], - "@deepseek-ai/dsh-file-reference/invariant": ["./packages/context/file-reference/src/invariant.ts"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], - "@deepseek-ai/dsh-file-reference-local/invariant": ["./packages/context/file-reference-local/src/invariant.ts"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], "@deepseek-ai/dsh-fs/invariant": ["./packages/fs/fs/src/invariant.ts"], "@deepseek-ai/dsh-fs-e2b": ["./packages/e2b/fs-e2b/src"], - "@deepseek-ai/dsh-fs-e2b/invariant": ["./packages/e2b/fs-e2b/src/invariant.ts"], "@deepseek-ai/dsh-fs-local": ["./packages/fs/fs-local/src"], - "@deepseek-ai/dsh-fs-local/invariant": ["./packages/fs/fs-local/src/invariant.ts"], "@deepseek-ai/dsh-fs-observation-policy": ["./packages/fs/fs-observation-policy/src"], - "@deepseek-ai/dsh-fs-observation-policy/invariant": ["./packages/fs/fs-observation-policy/src/invariant.ts"], "@deepseek-ai/dsh-fs-sandbox": ["./packages/fs/fs-sandbox/src"], - "@deepseek-ai/dsh-fs-sandbox/invariant": ["./packages/fs/fs-sandbox/src/invariant.ts"], "@deepseek-ai/dsh-goal": ["./packages/goal/goal/src"], "@deepseek-ai/dsh-goal/invariant": ["./packages/goal/goal/src/invariant.ts"], "@deepseek-ai/dsh-goal-round-driver": ["./packages/goal/goal-round-driver/src"], "@deepseek-ai/dsh-goal-round-driver/invariant": ["./packages/goal/goal-round-driver/src/invariant.ts"], "@deepseek-ai/dsh-headless": ["./packages/bundle/headless/src"], - "@deepseek-ai/dsh-headless/invariant": ["./packages/bundle/headless/src/invariant.ts"], "@deepseek-ai/dsh-home-paths": ["./packages/util/home-paths/src"], - "@deepseek-ai/dsh-home-paths/invariant": ["./packages/util/home-paths/src/invariant.ts"], "@deepseek-ai/dsh-hook-protocol": ["./packages/hooks/hook-protocol/src"], "@deepseek-ai/dsh-hook-protocol/invariant": ["./packages/hooks/hook-protocol/src/invariant.ts"], "@deepseek-ai/dsh-hooks-claude-code": ["./packages/hooks/hooks-claude-code/src"], - "@deepseek-ai/dsh-hooks-claude-code/invariant": ["./packages/hooks/hooks-claude-code/src/invariant.ts"], "@deepseek-ai/dsh-hooks-codex": ["./packages/hooks/hooks-codex/src"], - "@deepseek-ai/dsh-hooks-codex/invariant": ["./packages/hooks/hooks-codex/src/invariant.ts"], - "@deepseek-ai/dsh-invariants/invariant": ["./packages/runtime-diagnostics/invariants/src/invariant.ts"], "@deepseek-ai/dsh-jobs": ["./packages/jobs/jobs/src"], "@deepseek-ai/dsh-jobs/invariant": ["./packages/jobs/jobs/src/invariant.ts"], "@deepseek-ai/dsh-jobs-local": ["./packages/jobs/jobs-local/src"], - "@deepseek-ai/dsh-jobs-local/invariant": ["./packages/jobs/jobs-local/src/invariant.ts"], "@deepseek-ai/dsh-launch-environment": ["./packages/util/launch-environment/src"], - "@deepseek-ai/dsh-launch-environment/invariant": ["./packages/util/launch-environment/src/invariant.ts"], "@deepseek-ai/dsh-llm": ["./packages/llm/llm/src"], "@deepseek-ai/dsh-llm/invariant": ["./packages/llm/llm/src/invariant.ts"], "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-deepseek/invariant": ["./packages/llm/llm-deepseek/src/invariant.ts"], "@deepseek-ai/dsh-llm-mock-server": ["./packages/test-support/llm-mock-server/src"], - "@deepseek-ai/dsh-llm-mock-server/invariant": ["./packages/test-support/llm-mock-server/src/invariant.ts"], "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm/llm-pi-ai/src"], - "@deepseek-ai/dsh-llm-pi-ai/invariant": ["./packages/llm/llm-pi-ai/src/invariant.ts"], "@deepseek-ai/dsh-llm-replay": ["./packages/test-support/llm-replay/src"], - "@deepseek-ai/dsh-llm-replay/invariant": ["./packages/test-support/llm-replay/src/invariant.ts"], "@deepseek-ai/dsh-llm-retry": ["./packages/llm/llm-retry/src"], "@deepseek-ai/dsh-llm-retry/invariant": ["./packages/llm/llm-retry/src/invariant.ts"], "@deepseek-ai/dsh-loader-smoke": ["./packages/test-support/loader-smoke/src"], - "@deepseek-ai/dsh-loader-smoke/invariant": ["./packages/test-support/loader-smoke/src/invariant.ts"], "@deepseek-ai/dsh-lsp": ["./packages/lsp/lsp/src"], - "@deepseek-ai/dsh-lsp/invariant": ["./packages/lsp/lsp/src/invariant.ts"], "@deepseek-ai/dsh-lsp-stdio": ["./packages/lsp/lsp-stdio/src"], - "@deepseek-ai/dsh-lsp-stdio/invariant": ["./packages/lsp/lsp-stdio/src/invariant.ts"], "@deepseek-ai/dsh-mcp-client": ["./packages/mcp/mcp-client/src"], - "@deepseek-ai/dsh-mcp-client/invariant": ["./packages/mcp/mcp-client/src/invariant.ts"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], - "@deepseek-ai/dsh-message-feedback/invariant": ["./packages/feedback/message-feedback/src/invariant.ts"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], - "@deepseek-ai/dsh-native-command/invariant": ["./packages/util/native-command/src/invariant.ts"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], - "@deepseek-ai/dsh-output-retention/invariant": ["./packages/util/output-retention/src/invariant.ts"], "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"], "@deepseek-ai/dsh-permission-presets/invariant": ["./packages/interaction/permission-presets/src/invariant.ts"], "@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"], - "@deepseek-ai/dsh-persona/invariant": ["./packages/preset/persona/src/invariant.ts"], "@deepseek-ai/dsh-plan-mode": ["./packages/plan/plan-mode/src"], "@deepseek-ai/dsh-plan-mode/invariant": ["./packages/plan/plan-mode/src/invariant.ts"], "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"], - "@deepseek-ai/dsh-plugin-package-inventory-deepseek/invariant": ["./packages/llm/plugin-package-inventory-deepseek/src/invariant.ts"], - "@deepseek-ai/dsh-pwsh-local/invariant": ["./packages/shell/pwsh-local/src/invariant.ts"], "@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"], - "@deepseek-ai/dsh-pwsh-sandbox/invariant": ["./packages/shell/pwsh-sandbox/src/invariant.ts"], "@deepseek-ai/dsh-repeat-tool-reminder": ["./packages/guard/repeat-tool-reminder/src"], - "@deepseek-ai/dsh-repeat-tool-reminder/invariant": ["./packages/guard/repeat-tool-reminder/src/invariant.ts"], "@deepseek-ai/dsh-sandbox": ["./packages/sandbox/sandbox/src"], - "@deepseek-ai/dsh-sandbox/invariant": ["./packages/sandbox/sandbox/src/invariant.ts"], "@deepseek-ai/dsh-sandbox-local": ["./packages/sandbox/sandbox-local/src"], - "@deepseek-ai/dsh-sandbox-local/invariant": ["./packages/sandbox/sandbox-local/src/invariant.ts"], "@deepseek-ai/dsh-sandbox-policy": ["./packages/sandbox/sandbox-policy/src"], "@deepseek-ai/dsh-sandbox-policy/invariant": ["./packages/sandbox/sandbox-policy/src/invariant.ts"], "@deepseek-ai/dsh-sandbox-windows-acl": ["./packages/sandbox/sandbox-windows-acl/src"], - "@deepseek-ai/dsh-sandbox-windows-acl/invariant": ["./packages/sandbox/sandbox-windows-acl/src/invariant.ts"], "@deepseek-ai/dsh-schedule": ["./packages/schedule/schedule/src"], "@deepseek-ai/dsh-schedule/invariant": ["./packages/schedule/schedule/src/invariant.ts"], "@deepseek-ai/dsh-scope": ["./packages/core/scope/src"], "@deepseek-ai/dsh-sdk-app": ["./packages/bundle/sdk-app/src"], - "@deepseek-ai/dsh-sdk-app/invariant": ["./packages/bundle/sdk-app/src/invariant.ts"], "@deepseek-ai/dsh-sdk-minimal": ["./packages/bundle/sdk-minimal/src"], - "@deepseek-ai/dsh-sdk-minimal/invariant": ["./packages/bundle/sdk-minimal/src/invariant.ts"], "@deepseek-ai/dsh-session": ["./packages/core/session/src"], "@deepseek-ai/dsh-session-checkpoint-policy": ["./packages/session/session-checkpoint-policy/src"], - "@deepseek-ai/dsh-session-checkpoint-policy/invariant": ["./packages/session/session-checkpoint-policy/src/invariant.ts"], "@deepseek-ai/dsh-session-log-deepseek": ["./packages/session/session-log-deepseek/src"], "@deepseek-ai/dsh-session-log-deepseek/invariant": ["./packages/session/session-log-deepseek/src/invariant.ts"], "@deepseek-ai/dsh-session-log-export": ["./packages/session-query/session-log-export/src"], - "@deepseek-ai/dsh-session-log-export/invariant": ["./packages/session-query/session-log-export/src/invariant.ts"], "@deepseek-ai/dsh-session-persistence": ["./packages/session/session-persistence/src"], - "@deepseek-ai/dsh-session-persistence/invariant": ["./packages/session/session-persistence/src/invariant.ts"], "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session/session-persistence-jsonl/src"], - "@deepseek-ai/dsh-session-persistence-jsonl/invariant": ["./packages/session/session-persistence-jsonl/src/invariant.ts"], "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-session-persistence-sqlite/invariant": ["./packages/session/session-persistence-sqlite/src/invariant.ts"], "@deepseek-ai/dsh-session-projection": ["./packages/session/session-projection/src"], - "@deepseek-ai/dsh-session-projection/invariant": ["./packages/session/session-projection/src/invariant.ts"], "@deepseek-ai/dsh-session-projection-cache": ["./packages/session/session-projection-cache/src"], - "@deepseek-ai/dsh-session-projection-cache/invariant": ["./packages/session/session-projection-cache/src/invariant.ts"], "@deepseek-ai/dsh-session-query": ["./packages/session-query/session-query/src"], - "@deepseek-ai/dsh-session-query/invariant": ["./packages/session-query/session-query/src/invariant.ts"], "@deepseek-ai/dsh-session-query-sqlite": ["./packages/session-query/session-query-sqlite/src"], - "@deepseek-ai/dsh-session-query-sqlite/invariant": ["./packages/session-query/session-query-sqlite/src/invariant.ts"], "@deepseek-ai/dsh-session-reference": ["./packages/context/session-reference/src"], - "@deepseek-ai/dsh-session-reference/invariant": ["./packages/context/session-reference/src/invariant.ts"], "@deepseek-ai/dsh-session-snapshot": ["./packages/test-support/session-snapshot/src"], - "@deepseek-ai/dsh-session-snapshot/invariant": ["./packages/test-support/session-snapshot/src/invariant.ts"], "@deepseek-ai/dsh-session-stats": ["./packages/session/session-stats/src"], - "@deepseek-ai/dsh-session-stats/invariant": ["./packages/session/session-stats/src/invariant.ts"], "@deepseek-ai/dsh-session-telemetry": ["./packages/session/session-telemetry/src"], - "@deepseek-ai/dsh-session-telemetry/invariant": ["./packages/session/session-telemetry/src/invariant.ts"], "@deepseek-ai/dsh-session-telemetry-otel": ["./packages/session/session-telemetry-otel/src"], - "@deepseek-ai/dsh-session-telemetry-otel/invariant": ["./packages/session/session-telemetry-otel/src/invariant.ts"], "@deepseek-ai/dsh-session-title": ["./packages/session/session-title/src"], "@deepseek-ai/dsh-session-title/invariant": ["./packages/session/session-title/src/invariant.ts"], "@deepseek-ai/dsh-session-title-all-prompts-llm": ["./packages/session/session-title-all-prompts-llm/src"], - "@deepseek-ai/dsh-session-title-all-prompts-llm/invariant": ["./packages/session/session-title-all-prompts-llm/src/invariant.ts"], "@deepseek-ai/dsh-session-title-first-prompt-llm": ["./packages/session/session-title-first-prompt-llm/src"], - "@deepseek-ai/dsh-session-title-first-prompt-llm/invariant": ["./packages/session/session-title-first-prompt-llm/src/invariant.ts"], "@deepseek-ai/dsh-session-title-llm": ["./packages/session/session-title-llm/src"], - "@deepseek-ai/dsh-session-title-llm/invariant": ["./packages/session/session-title-llm/src/invariant.ts"], "@deepseek-ai/dsh-settings": ["./packages/settings/settings/src"], "@deepseek-ai/dsh-settings/invariant": ["./packages/settings/settings/src/invariant.ts"], "@deepseek-ai/dsh-settings-file": ["./packages/settings/settings-file/src"], - "@deepseek-ai/dsh-settings-file/invariant": ["./packages/settings/settings-file/src/invariant.ts"], "@deepseek-ai/dsh-shell": ["./packages/shell/shell/src"], - "@deepseek-ai/dsh-shell/invariant": ["./packages/shell/shell/src/invariant.ts"], - "@deepseek-ai/dsh-shell-env/invariant": ["./packages/shell/shell-env/src/invariant.ts"], "@deepseek-ai/dsh-skill": ["./packages/skill/skill/src"], - "@deepseek-ai/dsh-skill/invariant": ["./packages/skill/skill/src/invariant.ts"], "@deepseek-ai/dsh-skill-badge": ["./packages/skill/skill-badge/src"], - "@deepseek-ai/dsh-skill-badge/invariant": ["./packages/skill/skill-badge/src/invariant.ts"], "@deepseek-ai/dsh-skill-filesystem": ["./packages/skill/skill-filesystem/src"], - "@deepseek-ai/dsh-skill-filesystem/invariant": ["./packages/skill/skill-filesystem/src/invariant.ts"], "@deepseek-ai/dsh-spill": ["./packages/spill/spill/src"], - "@deepseek-ai/dsh-spill/invariant": ["./packages/spill/spill/src/invariant.ts"], "@deepseek-ai/dsh-spill-local": ["./packages/spill/spill-local/src"], - "@deepseek-ai/dsh-spill-local/invariant": ["./packages/spill/spill-local/src/invariant.ts"], "@deepseek-ai/dsh-spill-policy": ["./packages/spill/spill-policy/src"], - "@deepseek-ai/dsh-spill-policy/invariant": ["./packages/spill/spill-policy/src/invariant.ts"], "@deepseek-ai/dsh-storage": ["./packages/storage/storage/src"], - "@deepseek-ai/dsh-storage/invariant": ["./packages/storage/storage/src/invariant.ts"], "@deepseek-ai/dsh-storage-domain": ["./packages/storage/storage-domain/src"], "@deepseek-ai/dsh-storage-domain/invariant": ["./packages/storage/storage-domain/src/invariant.ts"], "@deepseek-ai/dsh-storage-json": ["./packages/storage/storage-json/src"], - "@deepseek-ai/dsh-storage-json/invariant": ["./packages/storage/storage-json/src/invariant.ts"], "@deepseek-ai/dsh-storage-sqlite": ["./packages/storage/storage-sqlite/src"], - "@deepseek-ai/dsh-storage-sqlite/invariant": ["./packages/storage/storage-sqlite/src/invariant.ts"], "@deepseek-ai/dsh-subagent": ["./packages/subagent/subagent/src"], "@deepseek-ai/dsh-subagent/invariant": ["./packages/subagent/subagent/src/invariant.ts"], "@deepseek-ai/dsh-subagent-acp": ["./packages/subagent/subagent-acp/src"], - "@deepseek-ai/dsh-subagent-acp/invariant": ["./packages/subagent/subagent-acp/src/invariant.ts"], "@deepseek-ai/dsh-subagent-claude-code": ["./packages/subagent/subagent-claude-code/src"], - "@deepseek-ai/dsh-subagent-claude-code/invariant": ["./packages/subagent/subagent-claude-code/src/invariant.ts"], "@deepseek-ai/dsh-subagent-codex": ["./packages/subagent/subagent-codex/src"], - "@deepseek-ai/dsh-subagent-codex/invariant": ["./packages/subagent/subagent-codex/src/invariant.ts"], "@deepseek-ai/dsh-subagent-dsh-sdk": ["./packages/subagent/subagent-dsh-sdk/src"], - "@deepseek-ai/dsh-subagent-dsh-sdk/invariant": ["./packages/subagent/subagent-dsh-sdk/src/invariant.ts"], "@deepseek-ai/dsh-subagent-fork-in-process": ["./packages/subagent/subagent-fork-in-process/src"], - "@deepseek-ai/dsh-subagent-fork-in-process/invariant": ["./packages/subagent/subagent-fork-in-process/src/invariant.ts"], "@deepseek-ai/dsh-subagent-in-process-driver": ["./packages/subagent/subagent-in-process-driver/src"], - "@deepseek-ai/dsh-subagent-in-process-driver/invariant": ["./packages/subagent/subagent-in-process-driver/src/invariant.ts"], "@deepseek-ai/dsh-subagent-spawn-in-process": ["./packages/subagent/subagent-spawn-in-process/src"], - "@deepseek-ai/dsh-subagent-spawn-in-process/invariant": ["./packages/subagent/subagent-spawn-in-process/src/invariant.ts"], "@deepseek-ai/dsh-subprocess": ["./packages/subprocess/subprocess/src"], - "@deepseek-ai/dsh-subprocess/invariant": ["./packages/subprocess/subprocess/src/invariant.ts"], "@deepseek-ai/dsh-subprocess-e2b": ["./packages/e2b/subprocess-e2b/src"], - "@deepseek-ai/dsh-subprocess-e2b/invariant": ["./packages/e2b/subprocess-e2b/src/invariant.ts"], "@deepseek-ai/dsh-subprocess-local": ["./packages/subprocess/subprocess-local/src"], - "@deepseek-ai/dsh-subprocess-local/invariant": ["./packages/subprocess/subprocess-local/src/invariant.ts"], "@deepseek-ai/dsh-system-prompt": ["./packages/core/system-prompt/src"], "@deepseek-ai/dsh-system-prompt/invariant": ["./packages/core/system-prompt/src/invariant.ts"], "@deepseek-ai/dsh-terminal": ["./packages/terminal/terminal/src"], - "@deepseek-ai/dsh-terminal/invariant": ["./packages/terminal/terminal/src/invariant.ts"], "@deepseek-ai/dsh-terminal-bash": ["./packages/terminal/terminal-bash/src"], - "@deepseek-ai/dsh-terminal-bash/invariant": ["./packages/terminal/terminal-bash/src/invariant.ts"], "@deepseek-ai/dsh-time-context": ["./packages/context/time-context/src"], "@deepseek-ai/dsh-time-context/invariant": ["./packages/context/time-context/src/invariant.ts"], "@deepseek-ai/dsh-timeout": ["./packages/util/timeout/src"], - "@deepseek-ai/dsh-timeout/invariant": ["./packages/util/timeout/src/invariant.ts"], "@deepseek-ai/dsh-tmux-context": ["./packages/context/tmux-context/src"], - "@deepseek-ai/dsh-tmux-context/invariant": ["./packages/context/tmux-context/src/invariant.ts"], "@deepseek-ai/dsh-token-meter": ["./packages/llm/token-meter/src"], - "@deepseek-ai/dsh-token-meter/invariant": ["./packages/llm/token-meter/src/invariant.ts"], "@deepseek-ai/dsh-tool-ask-user": ["./packages/interaction/tool-ask-user/src"], - "@deepseek-ai/dsh-tool-ask-user/invariant": ["./packages/interaction/tool-ask-user/src/invariant.ts"], "@deepseek-ai/dsh-tool-bash": ["./packages/shell/tool-bash/src"], - "@deepseek-ai/dsh-tool-bash/invariant": ["./packages/shell/tool-bash/src/invariant.ts"], "@deepseek-ai/dsh-tool-bash-persistent": ["./packages/shell/tool-bash-persistent/src"], - "@deepseek-ai/dsh-tool-bash-persistent/invariant": ["./packages/shell/tool-bash-persistent/src/invariant.ts"], "@deepseek-ai/dsh-tool-cordis": ["./packages/extensions/tool-cordis/src"], - "@deepseek-ai/dsh-tool-cordis/invariant": ["./packages/extensions/tool-cordis/src/invariant.ts"], "@deepseek-ai/dsh-tool-fs": ["./packages/fs/tool-fs/src"], - "@deepseek-ai/dsh-tool-fs/invariant": ["./packages/fs/tool-fs/src/invariant.ts"], "@deepseek-ai/dsh-tool-fs-search": ["./packages/fs/tool-fs-search/src"], - "@deepseek-ai/dsh-tool-fs-search/invariant": ["./packages/fs/tool-fs-search/src/invariant.ts"], "@deepseek-ai/dsh-tool-goal": ["./packages/goal/tool-goal/src"], - "@deepseek-ai/dsh-tool-goal/invariant": ["./packages/goal/tool-goal/src/invariant.ts"], "@deepseek-ai/dsh-tool-jobs": ["./packages/jobs/tool-jobs/src"], - "@deepseek-ai/dsh-tool-jobs/invariant": ["./packages/jobs/tool-jobs/src/invariant.ts"], "@deepseek-ai/dsh-tool-lsp": ["./packages/lsp/tool-lsp/src"], - "@deepseek-ai/dsh-tool-lsp/invariant": ["./packages/lsp/tool-lsp/src/invariant.ts"], - "@deepseek-ai/dsh-tool-pwsh/invariant": ["./packages/shell/tool-pwsh/src/invariant.ts"], "@deepseek-ai/dsh-tool-pwsh-persistent": ["./packages/shell/tool-pwsh-persistent/src"], - "@deepseek-ai/dsh-tool-pwsh-persistent/invariant": ["./packages/shell/tool-pwsh-persistent/src/invariant.ts"], "@deepseek-ai/dsh-tool-ralph": ["./packages/workflow/tool-ralph/src"], - "@deepseek-ai/dsh-tool-ralph/invariant": ["./packages/workflow/tool-ralph/src/invariant.ts"], "@deepseek-ai/dsh-tool-session-query": ["./packages/session-query/tool-session-query/src"], - "@deepseek-ai/dsh-tool-session-query/invariant": ["./packages/session-query/tool-session-query/src/invariant.ts"], "@deepseek-ai/dsh-tool-skill": ["./packages/skill/tool-skill/src"], - "@deepseek-ai/dsh-tool-skill/invariant": ["./packages/skill/tool-skill/src/invariant.ts"], "@deepseek-ai/dsh-tool-str-replace-editor": ["./packages/fs/tool-str-replace-editor/src"], - "@deepseek-ai/dsh-tool-str-replace-editor/invariant": ["./packages/fs/tool-str-replace-editor/src/invariant.ts"], "@deepseek-ai/dsh-tool-subagent": ["./packages/subagent/tool-subagent/src"], "@deepseek-ai/dsh-tool-subagent/invariant": ["./packages/subagent/tool-subagent/src/invariant.ts"], "@deepseek-ai/dsh-tool-subagent-control": ["./packages/subagent/tool-subagent-control/src"], - "@deepseek-ai/dsh-tool-subagent-control/invariant": ["./packages/subagent/tool-subagent-control/src/invariant.ts"], "@deepseek-ai/dsh-tool-subagent-report": ["./packages/subagent/tool-subagent-report/src"], - "@deepseek-ai/dsh-tool-subagent-report/invariant": ["./packages/subagent/tool-subagent-report/src/invariant.ts"], "@deepseek-ai/dsh-tool-terminal": ["./packages/terminal/tool-terminal/src"], - "@deepseek-ai/dsh-tool-terminal/invariant": ["./packages/terminal/tool-terminal/src/invariant.ts"], "@deepseek-ai/dsh-tool-todo": ["./packages/todo/tool-todo/src"], "@deepseek-ai/dsh-tool-todo/invariant": ["./packages/todo/tool-todo/src/invariant.ts"], "@deepseek-ai/dsh-tool-web": ["./packages/web/tool-web/src"], - "@deepseek-ai/dsh-tool-web/invariant": ["./packages/web/tool-web/src/invariant.ts"], "@deepseek-ai/dsh-tool-workflow": ["./packages/workflow/tool-workflow/src"], "@deepseek-ai/dsh-tool-workflow/invariant": ["./packages/workflow/tool-workflow/src/invariant.ts"], "@deepseek-ai/dsh-tools": ["./packages/core/tools/src"], @@ -540,29 +414,19 @@ "@deepseek-ai/dsh-user-approval": ["./packages/interaction/user-approval/src"], "@deepseek-ai/dsh-user-approval/invariant": ["./packages/interaction/user-approval/src/invariant.ts"], "@deepseek-ai/dsh-user-questions": ["./packages/interaction/user-questions/src"], - "@deepseek-ai/dsh-user-questions/invariant": ["./packages/interaction/user-questions/src/invariant.ts"], "@deepseek-ai/dsh-web": ["./packages/web/web/src"], - "@deepseek-ai/dsh-web/invariant": ["./packages/web/web/src/invariant.ts"], "@deepseek-ai/dsh-web-app": ["./packages/bundle/web-app/src"], - "@deepseek-ai/dsh-web-app/invariant": ["./packages/bundle/web-app/src/invariant.ts"], "@deepseek-ai/dsh-web-fetch-http": ["./packages/web/web-fetch-http/src"], - "@deepseek-ai/dsh-web-fetch-http/invariant": ["./packages/web/web-fetch-http/src/invariant.ts"], "@deepseek-ai/dsh-web-search-deepseek": ["./packages/web/web-search-deepseek/src"], - "@deepseek-ai/dsh-web-search-deepseek/invariant": ["./packages/web/web-search-deepseek/src/invariant.ts"], "@deepseek-ai/dsh-web-search-exa": ["./packages/web/web-search-exa/src"], - "@deepseek-ai/dsh-web-search-exa/invariant": ["./packages/web/web-search-exa/src/invariant.ts"], "@deepseek-ai/dsh-web-search-perplexity": ["./packages/web/web-search-perplexity/src"], - "@deepseek-ai/dsh-web-search-perplexity/invariant": ["./packages/web/web-search-perplexity/src/invariant.ts"], "@deepseek-ai/dsh-webhook": ["./packages/webhook/webhook/src"], "@deepseek-ai/dsh-webhook/invariant": ["./packages/webhook/webhook/src/invariant.ts"], "@deepseek-ai/dsh-webhook-github": ["./packages/webhook/webhook-github/src"], - "@deepseek-ai/dsh-webhook-github/invariant": ["./packages/webhook/webhook-github/src/invariant.ts"], "@deepseek-ai/dsh-win32-process": ["./packages/subprocess/win32-process/src"], - "@deepseek-ai/dsh-win32-process/invariant": ["./packages/subprocess/win32-process/src/invariant.ts"], "@deepseek-ai/dsh-workflow": ["./packages/workflow/workflow/src"], "@deepseek-ai/dsh-workflow/invariant": ["./packages/workflow/workflow/src/invariant.ts"], "@deepseek-ai/dsh-workflow-worker-thread": ["./packages/workflow/workflow-worker-thread/src"], - "@deepseek-ai/dsh-workflow-worker-thread/invariant": ["./packages/workflow/workflow-worker-thread/src/invariant.ts"], "@deepseek-ai/dsh-workspace": ["./packages/workspace/workspace/src"], "@deepseek-ai/dsh-workspace/invariant": ["./packages/workspace/workspace/src/invariant.ts"] // END generated package aliases diff --git a/vitest.config.ts b/vitest.config.ts index ecfd00ded4..1930868336 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -227,7 +227,6 @@ export default defineConfig({ // 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}', From 5660f44d29f47fca2612c92ecffe6fb699c486f1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 13:11:10 +0800 Subject: [PATCH 032/429] perf(session): separate indexed and snapshot log reads --- ...ev-invariants-over-deep-readonly.i18n.yaml | 4 +- ...06-11-dev-invariants-over-deep-readonly.md | 6 +- ...11-dev-invariants-over-deep-readonly.zh.md | 6 +- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...-26-packed-chunk-rows-by-default.i18n.yaml | 4 +- ...2026-07-26-packed-chunk-rows-by-default.md | 2 +- ...6-07-26-packed-chunk-rows-by-default.zh.md | 2 +- ...e-session-jsonl-restore-pipeline.i18n.yaml | 4 +- ...05-large-session-jsonl-restore-pipeline.md | 2 +- ...large-session-jsonl-restore-pipeline.zh.md | 2 +- ...26-08-21-session-log-read-intent.i18n.yaml | 6 + .../2026-08-21-session-log-read-intent.md | 32 +++++ .../2026-08-21-session-log-read-intent.zh.md | 32 +++++ ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 2 +- ...07-29-human-transcript-append-origin.zh.md | 2 +- ...ontext-meter-blind-to-compaction.i18n.yaml | 4 +- ...08-05-context-meter-blind-to-compaction.md | 2 +- ...05-context-meter-blind-to-compaction.zh.md | 2 +- ...26-08-05-context-form-vocabulary.i18n.yaml | 4 +- .../2026-08-05-context-form-vocabulary.md | 2 +- .../2026-08-05-context-form-vocabulary.zh.md | 2 +- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 2 +- ...-05-feedback-gated-session-telemetry.zh.md | 2 +- ...2026-07-06-recallable-compaction.i18n.yaml | 4 +- .../2026-07-06-recallable-compaction.md | 2 +- .../2026-07-06-recallable-compaction.zh.md | 2 +- .../headless/tests/coding-task.e2e.ts | 2 +- .../profiles/headless/tests/compaction.e2e.ts | 2 +- .../profiles/headless/tests/full-loop.e2e.ts | 2 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 6 +- .../profiles/headless/tests/resume.e2e.ts | 2 +- .../profiles/headless/tests/todo-write.e2e.ts | 2 +- .../tests/chat-continuous-conversation.e2e.ts | 2 +- apps/web/tests/chat-long-interactions.e2e.ts | 22 ++-- apps/web/tests/chat-scroll-fixture.ts | 2 +- apps/web/tests/complex-history.perf.ts | 2 +- .../tests/goal-command-presentation.e2e.ts | 2 +- apps/web/tests/markdown-cjk-strong.e2e.ts | 2 +- apps/web/tests/markdown-images.e2e.ts | 2 +- .../tests/markdown-inline-code-links.e2e.ts | 2 +- apps/web/tests/markdown-wide-table.e2e.ts | 2 +- apps/web/tests/math-rendering.e2e.ts | 2 +- apps/web/tests/minimal-preset.snapshot.ts | 2 +- apps/web/tests/produced-file-mentions.e2e.ts | 2 +- apps/web/tests/produced-files.e2e.ts | 2 +- apps/web/tests/reference-composer.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 4 +- apps/web/tests/schedule-after.e2e.ts | 16 +-- apps/web/tests/seeded-history.e2e.ts | 2 +- apps/web/tests/settings-chrome.e2e.ts | 8 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +- apps/web/tests/subagent-interrupt.e2e.ts | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 31 +++-- docs/subsystems/session.zh.md | 31 +++-- packages/acp/acp/tests/approval.spec.ts | 2 +- packages/acp/acp/tests/bridge.spec.ts | 4 +- packages/acp/acp/tests/turns.spec.ts | 10 +- packages/api/remotes/tests/built-lib.e2e.ts | 4 +- .../api/session-controller/src/commands.ts | 2 +- .../api/session-controller/src/history.ts | 2 +- packages/api/session-controller/src/index.ts | 2 +- .../tests/agent.host.spec.ts | 8 +- .../tests/session-cold.host.spec.ts | 2 +- .../tests/session-fork.host.spec.ts | 12 +- .../session-history-journal.host.spec.ts | 4 +- .../tests/session-projections.host.spec.ts | 4 +- .../tests/session-rename.host.spec.ts | 2 +- .../tests/transport.host.spec.ts | 27 ++-- packages/bundle/headless/src/index.ts | 14 +- .../tests/command-compact.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../compaction/compaction-basic/src/region.ts | 13 +- .../tests/compaction-basic.spec.ts | 52 ++++---- .../tests/compaction-loop-repro.spec.ts | 18 +-- .../tests/manual-compaction.spec.ts | 60 ++++----- .../src/index.ts | 2 +- .../tests/tool-result-pruner.spec.ts | 8 +- .../compaction/compaction/src/invariant.ts | 5 +- .../compaction/compaction/src/tool-pairing.ts | 16 +-- .../compaction/tests/compaction.spec.ts | 6 +- .../compaction/tests/invariant.spec.ts | 16 +-- .../compaction/tests/tool-pairing.spec.ts | 45 +++---- .../context/agent-instructions/src/index.ts | 4 +- .../context/agent-instructions/src/state.ts | 2 +- .../tests/agent-instructions.e2e.ts | 6 +- .../tests/agent-instructions.spec.ts | 68 +++++----- .../tests/session-reference.spec.ts | 2 +- packages/context/time-context/src/index.ts | 5 +- .../context/time-context/src/invariant.ts | 7 +- .../time-context/tests/time-context.spec.ts | 26 ++-- .../tmux-context/tests/tmux-context.spec.ts | 4 +- packages/core/agent-loop/src/invariant.ts | 2 +- .../core/agent-loop/src/runtime-context.ts | 4 +- .../agent-loop/tests/agent-initiator.spec.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 8 +- packages/core/agent-loop/tests/cancel.spec.ts | 78 +++++------ .../tests/contract-regressions.spec.ts | 78 +++++------ .../agent-loop/tests/coverage-edges.spec.ts | 36 +++--- .../agent-loop/tests/interception.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 114 ++++++++--------- .../core/agent-loop/tests/properties.spec.ts | 8 +- .../agent-loop/tests/request-cache.e2e.ts | 2 +- .../agent-loop/tests/request-error.spec.ts | 12 +- .../tests/request-reconstruction.spec.ts | 56 ++++---- packages/core/agent-loop/tests/resume.spec.ts | 14 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 6 +- .../core/agent-loop/tests/tool-calls.spec.ts | 2 +- .../core/agent-loop/tests/tool-order.spec.ts | 16 +-- packages/core/agent/src/inbox.ts | 2 +- packages/core/agent/tests/agent.spec.ts | 6 +- .../core/agent/tests/consumed-work.spec.ts | 26 ++-- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 + packages/core/session/README.zh.md | 6 + packages/core/session/src/chunk-rows.ts | 2 +- packages/core/session/src/index.ts | 57 ++++++--- packages/core/session/src/invariant.ts | 2 +- .../core/session/tests/derived-cache.spec.ts | 2 +- packages/core/session/tests/fork.spec.ts | 30 ++--- packages/core/session/tests/invariant.spec.ts | 4 +- .../core/session/tests/properties.spec.ts | 16 +-- .../core/session/tests/request-header.spec.ts | 6 +- packages/core/session/tests/session.spec.ts | 121 ++++++++++-------- packages/core/session/tests/surface.spec.ts | 40 +++--- packages/core/tools/src/invariant.ts | 2 +- packages/core/tools/tests/invariant.spec.ts | 2 +- packages/core/tools/tests/ptc.spec.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 6 +- .../experimental/agent-team/src/mailbox.ts | 2 +- .../experimental/agent-team/src/roster.ts | 4 +- .../agent-team/tests/invariant.spec.ts | 4 +- .../agent-team/tests/persistence.spec.ts | 2 +- .../agent-team/tests/team.spec.ts | 8 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../tests/command-feedback.spec.ts | 20 +-- .../tests/loader-composition.spec.ts | 8 +- .../message-feedback/tests/helpers.ts | 4 +- .../tests/message-feedback.spec.ts | 8 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- packages/fs/tool-fs/tests/tools.spec.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 12 +- .../goal/goal-round-driver/src/invariant.ts | 4 +- .../tests/goal-round-driver.spec.ts | 18 +-- packages/goal/goal/src/invariant.ts | 2 +- packages/goal/goal/tests/goal.spec.ts | 56 ++++---- packages/goal/goal/tests/projection.spec.ts | 2 +- packages/goal/tool-goal/src/authority.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 4 +- .../tests/repeat-tool-reminder.spec.ts | 6 +- packages/hooks/hook-protocol/src/invariant.ts | 2 +- .../hooks/hook-protocol/tests/events.spec.ts | 18 +-- .../hooks-claude-code/tests/bridge.spec.ts | 2 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 2 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- .../interaction/commands/src/invariant.ts | 4 +- .../commands/tests/commands.spec.ts | 10 +- .../permission-presets/src/invariant.ts | 2 +- .../tests/permission-presets.spec.ts | 36 +++--- .../tests/projection.spec.ts | 8 +- .../interaction/user-approval/src/index.ts | 4 +- .../user-approval/src/invariant.ts | 2 +- .../user-approval/tests/approval.spec.ts | 30 ++--- packages/llm/llm-retry/src/invariant.ts | 11 +- .../tests/loader-composition.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 50 ++++---- .../tests/transport-recovery.spec.ts | 34 ++--- packages/llm/token-meter/src/index.ts | 6 +- .../llm/token-meter/tests/token-meter.spec.ts | 14 +- packages/plan/plan-mode/src/invariant.ts | 2 +- .../plan/plan-mode/tests/integration.spec.ts | 10 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 78 +++++------ .../plan/plan-mode/tests/projection.spec.ts | 2 +- .../preset/agent-presets/tests/remote.spec.ts | 2 +- .../sandbox/sandbox-policy/src/invariant.ts | 2 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 2 +- packages/schedule/schedule/src/invariant.ts | 6 +- packages/schedule/schedule/src/runtime.ts | 2 +- packages/schedule/schedule/src/tools.ts | 2 +- .../schedule/schedule/tests/invariant.spec.ts | 10 +- .../schedule/tests/jsonl-restart.spec.ts | 2 +- .../schedule/schedule/tests/runtime.spec.ts | 34 ++--- .../schedule/schedule/tests/tools.spec.ts | 30 ++--- .../tests/loader-composition.host.spec.ts | 2 +- .../session-query-sqlite/src/index.ts | 2 +- .../session-query/session-query/src/corpus.ts | 4 +- .../session-query/src/observation.ts | 2 +- .../session-query/tests/observation.spec.ts | 2 +- .../session-query/tests/session-query.spec.ts | 6 +- .../src/workspace-access.ts | 2 +- .../tests/tool-session-query.spec.ts | 7 +- .../session/session-log-deepseek/src/index.ts | 18 +-- .../session-log-deepseek/src/invariant.ts | 2 +- .../session-log-deepseek/tests/upload.spec.ts | 17 +-- .../tests/jsonl.spec.ts | 10 +- .../session-persistence/src/coordinator.ts | 12 +- .../tests/persistence.spec.ts | 16 +-- .../session/session-projection/src/index.ts | 12 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry-otel/src/index.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 4 +- .../session-telemetry/src/coordinator.ts | 7 +- .../session-telemetry/tests/redact.spec.ts | 4 +- .../session-telemetry/tests/telemetry.spec.ts | 12 +- .../tests/provider.spec.ts | 2 +- .../session-title-llm/tests/llm.spec.ts | 8 +- packages/session/session-title/src/index.ts | 4 +- .../session-title/tests/provider.spec.ts | 6 +- .../session-title/tests/rename.spec.ts | 6 +- .../tests/service-contracts.spec.ts | 12 +- .../session-title/tests/session-title.spec.ts | 10 +- .../shell/tool-bash/tests/integration.spec.ts | 2 +- packages/shell/tool-bash/tests/tools.spec.ts | 4 +- packages/shell/tool-pwsh/tests/tools.spec.ts | 6 +- packages/skill/tool-skill/src/index.ts | 12 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 +- .../subagent-fork-in-process/src/index.ts | 2 +- .../tests/multi-subagent.spec.ts | 8 +- .../tests/subagent-fork-in-process.spec.ts | 16 +-- .../subagent-in-process-driver/src/index.ts | 2 +- .../tests/inheritance.spec.ts | 24 ++-- .../tests/preset-inheritance.spec.ts | 2 +- .../tests/structured.spec.ts | 12 +- .../tests/subagent-in-process-driver.spec.ts | 10 +- .../tests/spawn-in-process.e2e.ts | 2 +- .../tests/subagent-spawn-in-process.spec.ts | 6 +- .../subagent/subagent/src/descriptor-seed.ts | 2 +- packages/subagent/subagent/src/lifecycle.ts | 4 +- .../subagent/tests/continuation.spec.ts | 20 +-- .../tests/tool-subagent-report.spec.ts | 2 +- .../tests/model-selection-settings.spec.ts | 4 +- .../terminal-bash/tests/index.spec.ts | 4 +- .../fixtures/workspace-context-compaction.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 2 +- .../todo/tool-todo/tests/integration.spec.ts | 6 +- .../todo/tool-todo/tests/invariant.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 20 +-- .../workflow/tool-workflow/src/invariant.ts | 2 +- .../tool-workflow/tests/tool-workflow.spec.ts | 14 +- 251 files changed, 1371 insertions(+), 1207 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md create mode 100644 .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 44272f7ca1..3bbc91fa87 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.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-06-11-dev-invariants-over-deep-readonly.md -2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff +2026-06-11-dev-invariants-over-deep-readonly.md: 91d9ba2e459a02dc65b95a179d11e2f14af7e28d +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 750aee7ec543979f88a6bb606c00bd789de45c51 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 66980f1ee0..91d9ba2e45 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references. -The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. +The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered. @@ -48,12 +48,12 @@ Freezing history only when an invariants plugin is installed would make the core ### Clone only when deriving messages -Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute. +Detaching `deriveMessages()` would protect the most common request path but leave other readers of `snapshotEvents()`, `eventAt()`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute. ## Consequences - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. -- `session.events` exposes stable immutable snapshots instead of the private growing array. +- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array. - Request-side mutation cannot reach stored history through derived messages. - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability. - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package. diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 576e53e0b2..750aee7ec5 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 -被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 @@ -48,12 +48,12 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 仅在派生消息时克隆 -分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 +分离 `deriveMessages()` 能保护最常见的请求路径,但 `snapshotEvents()`、`eventAt()` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 ## 后果 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 -- `session.events` 暴露稳定的不可变快照,而非持续增长的私有数组。 +- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 1548b145b4..82350dcab5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 1fa442e8db2d8b2d2ec66730700c9c88dceddbae -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: ea9a6e247402e6a2d15fb4bfc0ebd6e65fc021df +2026-07-25-web-client-session-scope-and-provide-channel.md: 94d13a4cdc9771a7a500f5e6d5158e42ee67374a +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: c462e1f574fe1bc2db14672395a27e459e6ab7a8 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 1fa442e8db..94d13a4cdc 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -61,7 +61,7 @@ Session instances share the scope's lifecycle; liveness eligibility = host-liste A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. +- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index ea9a6e2474..c462e1f574 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -61,7 +61,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 +- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml index de962f5749..58dcf1ee20 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.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-26-packed-chunk-rows-by-default.md -2026-07-26-packed-chunk-rows-by-default.md: 14da6b3cbe650e80118e7c960c96bf618acd1e48 -2026-07-26-packed-chunk-rows-by-default.zh.md: f62a8e52a67adc960ac3150552594b4f061e6205 +2026-07-26-packed-chunk-rows-by-default.md: bd4b3b9f773afbf6aa7e88d51b6e842d6634c222 +2026-07-26-packed-chunk-rows-by-default.zh.md: eafe6632150aadd74395f4d0f09d064fb703a03d diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md index 14da6b3cbe..bd4b3b9f77 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md @@ -18,7 +18,7 @@ Reading is unconditional and layout-blind. Packed, unpacked, and mixed files loa ### Logical events and physical rows -The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics. +The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters the Session log or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics. The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs. diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md index f62a8e52a6..eafe663215 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md @@ -18,7 +18,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封 ### 逻辑事件与物理行 -JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。 +JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 Session 日志,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。 JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml index 6f00f147f3..18313964ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-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/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md -2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547 -2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 2cd0d2ca20074d6adb0735db08071638ae2ced88 +2026-08-05-large-session-jsonl-restore-pipeline.md: 309d9dc6bdb5c3160f3e6e76a8318915df58fe59 +2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 28acd3ebe804dca22a0619c257ff3ad9c09500a9 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md index eab53c6838..309d9dc6bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md @@ -41,7 +41,7 @@ Borrowed seeds used by ordinary creation and fork paths still take a JSON snapsh - **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix. - **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics. - **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal. -- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. +- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and Session read methods promise immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md index 2cd0d2ca20..28acd3ebe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md @@ -41,7 +41,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解 - **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。 - **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。 - **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。 -- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 +- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 Session 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml new file mode 100644 index 0000000000..c002ef3859 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.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-21-session-log-read-intent.md +2026-08-21-session-log-read-intent.md: 81cdd770886b24588052d9860e42e4a07650fb03 +2026-08-21-session-log-read-intent.zh.md: e5d0425981e6425323260661bee42281b1e96c98 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md new file mode 100644 index 0000000000..81cdd77088 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md @@ -0,0 +1,32 @@ +# Agent Note: Session log reads state their materialization cost + +Status: implemented + +English | [中文](2026-08-21-session-log-read-intent.zh.md) + +## Problem + +An all-purpose `Session.events` accessor hides an array-sized copy behind every read after an append. The full frozen snapshot can be cached, but streaming invalidates that cache for every new event, so a caller that only needs the log length or one event can repeatedly copy millions of references. Making the return value immutable does not require every read intent to pay that cost; event immutability is owned separately by the [source-owned session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md). + +## Decision + +`Session` exposes three cost-specific read operations. `seq` reads the current length in constant time, `eventAt(seq)` reads one event in constant time, and `snapshotEvents(fromSeq?, toSeqExclusive?)` explicitly materializes a frozen array for consumers that need array operations or a stable materialized range. Sequence parameters are non-negative log positions, not `Array.prototype.slice` offsets from the end. Recurring domain state such as the selected agent preset is read from a Session projection instead of rescanning live history. + +The complete current snapshot is cached until append because repeated whole-log consumers can share the same immutable array. A range snapshot copies only the selected references and is not cached: arbitrary range caching would retain unbounded arrays and require an eviction policy. Previously returned snapshots remain stable because accepted events are immutable and a snapshot array never grows after append. + +Recurring domain-state reads use [session projections](2026-08-19-session-projection-state-and-client-views.md) when the required value can be maintained incrementally. Raw-log snapshots remain appropriate for persistence, export, replay, and consumers whose output is the event sequence itself. This API makes materialization visible but does not attempt to eliminate every full-log fold in the same change. + +## Alternatives considered + +**Keep a cached `events` array accessor.** This preserves ordinary array syntax but makes scalar and indexed reads appear cheap while an append can turn either into a whole-log copy. + +**Return a custom immutable cut with array-like traversal operations.** A captured length could provide a stable constant-time cut over the growing log, but the abstraction would reimplement selected array semantics and keep expanding as callers request more operations. Explicit indexed reads, explicit materialization, and projections cover the shipped intents with a smaller public API. + +**Cache every materialized range or maintain an incremental chunked snapshot.** Range caching needs retention and eviction rules, while a chunked public representation changes consumers and serialization for a cost that many callers avoid through indexed reads or projections. These representations remain options if measured full-snapshot consumers justify them. + +## Consequences + +- Length and single-event reads do not copy the log. +- Full and ranged snapshots retain an explicit linear cost proportional to the selected event count. +- Consumers choose between raw history and incrementally maintained state at the call site. +- The public API does not emulate an array; callers materialize only when they need array operations. diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md new file mode 100644 index 0000000000..e5d0425981 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 会话日志读取显式表达物化成本 + +Status: implemented + +[English](2026-08-21-session-log-read-intent.md) | 中文 + +## 问题 + +通用的 `Session.events` 访问器会在每次 append 后的首次读取中隐藏一次与数组大小成正比的复制。完整冻结快照可以缓存,但流式输出的每个新事件都会使缓存失效,因此只需要日志长度或单个事件的调用方也可能反复复制数百万个引用。返回值不可变并不要求每种读取意图都承担该成本;事件不可变性由[源端拥有的会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)另行负责。 + +## 决策 + +`Session` 提供三种成本不同的读取操作。`seq` 以常数时间读取当前长度,`eventAt(seq)` 以常数时间读取一个事件,`snapshotEvents(fromSeq?, toSeqExclusive?)` 则为需要数组操作或稳定物化区间的消费方显式物化冻结数组。序列参数是非负日志位置,不是从末尾计算的 `Array.prototype.slice` 偏移量。所选 agent preset 这类重复读取的领域状态来自会话投影,而不是反复扫描活跃日志。 + +当前完整快照会缓存到下一次 append,使重复读取整个日志的消费方可以共享同一个不可变数组。区间快照只复制所选引用且不缓存:缓存任意区间会保留数量无界的数组,并要求额外的淘汰策略。先前返回的快照保持稳定,因为已接受的事件不可变,且快照数组不会在 append 后增长。 + +当所需值可以增量维护时,重复的领域状态读取使用[会话投影](2026-08-19-session-projection-state-and-client-views.zh.md)。持久化、导出、回放,以及输出本身就是事件序列的消费方仍适合使用原始日志快照。此 API 使物化成本显式可见,但不会在同一项改动中消除所有完整日志折叠。 + +## 曾考虑的替代方案 + +**保留缓存的 `events` 数组访问器。** 这保留了普通数组语法,但会让标量读取和索引读取看似廉价,而一次 append 就可能使其中任何一次读取变成完整日志复制。 + +**返回提供类数组遍历操作的自定义不可变 cut。** 捕获长度可以在持续增长的日志上提供稳定且常数时间的 cut,但随着调用方要求更多操作,该抽象会不断重新实现所选数组语义。显式索引读取、显式物化和投影以更小的公开 API 覆盖了已经交付的读取意图。 + +**缓存每个物化区间或维护增量分片快照。** 区间缓存需要保留与淘汰规则,而公开的分片表示会改变消费方和序列化方式;许多调用方已通过索引读取或投影避开这项成本。若实测的完整快照消费方证明有必要,仍可考虑这些表示。 + +## 后果 + +- 长度读取和单事件读取不会复制日志。 +- 完整快照和区间快照仍有与所选事件数量成正比的显式线性成本。 +- 消费方在调用点选择原始历史或增量维护的状态。 +- 公开 API 不模拟数组;调用方只在需要数组操作时物化。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 7593352560..d8a34eb622 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: 72cafb5a1a149bcdb73d885d4e4fc4ac01e48cd2 -2026-07-29-human-transcript-append-origin.zh.md: 79fcc56b7b60082ebd2946d70419a659ff5687b7 +2026-07-29-human-transcript-append-origin.md: 00f4cf09139bdba4c0fffcdc476b698d098154bc +2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 72cafb5a1a..00f4cf0913 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -8,7 +8,7 @@ English | [中文](2026-07-29-human-transcript-append-origin.zh.md) The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only `compaction/summary` event and the replacement that cites it. -Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. +Nothing was lost from the log. `Session.snapshotEvents()` still returned every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 79fcc56b7b..631ba71d84 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -8,7 +8,7 @@ Status: implemented 终端与宿主历史网关都把模型可见的 surface 当作 transcript(文本记录)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志 `compaction/summary` 事件与引用它的替换之间。 -日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 +日志本身没有丢失任何内容。`Session.snapshotEvents()` 仍返回每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index 0bf2cfa13f..abaae859ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.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-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: daf11b41236943d5fb35e9363c128ce470c7f4e9 -2026-08-05-context-meter-blind-to-compaction.zh.md: 6ea2f8814c4731fdf7c08d8b867cd7086ceccdbf +2026-08-05-context-meter-blind-to-compaction.md: a23e51771c74e350fd3fb04e3a23ab3cdfb6092e +2026-08-05-context-meter-blind-to-compaction.zh.md: 12fd41af0d8c8864a120f26d91148543f74b2e2b diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index daf11b4123..a23e51771c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -27,7 +27,7 @@ This reverses the "the ring, header, and bar length stay provider-exact" half of ## Alternatives considered -**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. +**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.eventAt(seq)`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. **Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index 6ea2f8814c..12fd41af0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -27,7 +27,7 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 备选方案 -**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 +**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.eventAt(seq)`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 **在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index f125c62af7..b56a2c78ac 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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-05-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: 9f20614dcd2ed0164efb51f938de9f74657d3bc3 -2026-08-05-context-form-vocabulary.zh.md: ebdabeab5a074f21e8fac9625a78c99cc401a6d3 +2026-08-05-context-form-vocabulary.md: c4f1414ad90ddefbac2d216f9109c95726769203 +2026-08-05-context-form-vocabulary.zh.md: 57af2be2b25ce9af36d7551fb271ef577e0f7b4c diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 9f20614dcd..c4f1414ad9 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -39,7 +39,7 @@ That move also relocates catalog **identity**: the republish digest now covers t Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. -The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. +The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.snapshotEvents()`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining source data as fields. Opaque is the documented default; the contract assigns these unsupported cases to it. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index ebdabeab5a..57af2be2b2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -39,7 +39,7 @@ Status: implemented 两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 -生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.snapshotEvents()` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index ad9994bc34..72e8ad9a15 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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-05-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: ce07e63681a659fb9bf758a24301c531f3f27e39 -2026-08-05-feedback-gated-session-telemetry.zh.md: 9657c7dc9243377233f20b265569829b06162fb5 +2026-08-05-feedback-gated-session-telemetry.md: 9dab135029bd4a5dd209157b48b6f7dca4040224 +2026-08-05-feedback-gated-session-telemetry.zh.md: 824aa1a04f3854163fdd16f4db7a7593540976e0 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index ce07e63681..9dab135029 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -18,7 +18,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. +Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when `session.eventAt(event.seq)` returns that exact `feedback/record` object. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 9657c7dc92..824aa1a04f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -18,7 +18,7 @@ Status: implemented 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)说明了按需路径为何使用权威日志而非记录副本。 -模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]`。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 +模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是 `session.eventAt(event.seq)` 返回完全相同的 `feedback/record` 对象。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index 7ce81010a9..fded384582 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/proposed/feature/2026-07-06-recallable-compaction.md -2026-07-06-recallable-compaction.md: 7309297af84a43a29e03feae815ac8c937a9da6a -2026-07-06-recallable-compaction.zh.md: 890493ae7d6a7b9066dd071b2cc8f642e9c84c86 +2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448 +2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index 7309297af8..f0cf1b0602 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor. - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint. -Both read `exec.agent.session.events` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. +Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. ### Cache and cost diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md index 890493ae7d..8e8793f28b 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -46,7 +46,7 @@ Status: proposed - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 -两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 +两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 ### 缓存与成本 diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts index 42b87eff8a..7f8e09c6c5 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -67,7 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… - const summary = finalText([...agent.session.events]).toLowerCase() + const summary = finalText([...agent.session.snapshotEvents()]).toLowerCase() expect(summary.length).toBeGreaterThan(0) // …and the world agrees: the test passes when WE run it, and the test diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 03de26d878..52889ce645 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] // A compaction ran: the start…end bracket landed in the real log. const starts = events.filter(e => e.type === 'compaction/start') diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts index a8825cc84d..22ca1e1976 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -34,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) expect(calls.some(event => event.data.name === 'bash')).toBe(true) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 0f076fd30e..2b0306fe7f 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,7 +366,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr + 'and return only the joined string.', }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events: SessionEvent[] = [...agent.session.events] + const events: SessionEvent[] = [...agent.session.snapshotEvents()] // The wire contract: every request this session made offered EXACTLY ONE // tool — run_code (the logged header snapshots the assembled list). @@ -418,11 +418,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - const events: SessionEvent[] = [...handle.agent.session.events] + const events: SessionEvent[] = [...handle.agent.session.snapshotEvents()] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { - const splice = handle.agent.session.events.findLast(event => event.type === 'agent/inbox/spliced' + const splice = handle.agent.session.snapshotEvents().findLast(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'agent-instructions')) const inserted = splice?.type === 'agent/inbox/spliced' ? splice.data.inserted.find(message => message.source.kind === 'agent-instructions') diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index 6767357b4e..fc4e815d2e 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,6 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. - expect(finalText([...resumed.session.events])).toContain(SECRET) + expect(finalText([...resumed.session.snapshotEvents()])).toContain(SECRET) }, 180_000) }) diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts index 014f1e3e69..eaff804410 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -37,7 +37,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a + 'Send all three in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] // The model actually called the tool. const calls = events.filter(event => event.type === 'tool/call') diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 8e4a5769ef..5e58d868c5 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -330,7 +330,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { } if (sessionId === undefined) throw new Error('continuous conversation completed no turn') - expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => ( + expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => ( event.type === 'turn/end' && event.data.reason.kind === 'completed' ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 5e3c02a436..54b1df5bd3 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -181,13 +181,13 @@ describe('web e2e: long Chat interaction contract', () => { const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN) const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1) const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2) - const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker) - const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker) + const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker) + const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker) const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) - const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker) - const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker) - const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( + const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker) + const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker) + const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`) @@ -311,9 +311,9 @@ describe('web e2e: long Chat interaction contract', () => { .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)) if (child === undefined) throw new Error('message branch did not create a child session') expect(child.session.header.seedLength).toBe(boundary.seq + 1) - expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true) - expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) - expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) + expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true) + expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) + expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) .getByRole('button').last() @@ -330,11 +330,11 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) expect(await composer.textContent()).toBe('') expect(await composer.isEnabled()).toBe(true) - expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) - expect(child.session.events.filter(event => ( + expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false) + expect(child.session.snapshotEvents().filter(event => ( event.type === 'user/message' && carries(event, CONTINUE_PROMPT) ))).toHaveLength(1) - const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => ( + const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' }) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index ed6c80da68..5f59aa48e5 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -164,7 +164,7 @@ function fixtureLog(session: Session): string { cwd: '{{cwd}}', delegationDepth: 0, }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index c43b919e5d..564334b1f3 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -313,7 +313,7 @@ function fixtureLog(session: Session): string { } return [ JSON.stringify(header), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 9f4ad2fc53..8df3a04fb0 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -109,7 +109,7 @@ describe('web e2e: /goal human transcript presentation', () => { const sessions = scaffold.ctx.sessions.list() expect(sessions).toHaveLength(1) - const persisted = sessions[0]?.events ?? [] + const persisted = sessions[0]?.snapshotEvents() ?? [] expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => event.type)).toEqual(['command/run', 'command/done']) expect(persisted.some(event => event.type === 'user/message')).toBe(false) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index b99f7f90d4..da82648c50 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -76,7 +76,7 @@ function markdownFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 54783b48a5..a888469339 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -131,7 +131,7 @@ function markdownImageFixture(remoteUrl: string): string { // the stats line renders its LLM segment only while the step's measured // milliseconds exceed zero, so a fixture that leaves the times unset lets // the replay's own speed decide whether the golden matches. - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index be3e30a380..d1da29922a 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -73,7 +73,7 @@ function markdownFixture(linkUrl: string): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 77e583bf45..28e98a89a6 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -139,7 +139,7 @@ function wideTableFixture(): string { return [ JSON.stringify(header), // Spaced event times, as the sibling markdown fixtures pin them. - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index c5e3e22a60..020d4b2a3a 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -77,7 +77,7 @@ function mathFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index b63e155d9d..19f829d5ac 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -72,7 +72,7 @@ describe('minimal agent preset', () => { it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') - expect(agentHandle.agent.session.events.some(event => event.type === 'user/message' + expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false) const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs') diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index 9e1772cdce..ca8fafc63e 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -107,7 +107,7 @@ function mentionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 0761639de5..5494a934e3 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -96,7 +96,7 @@ function producedFixture(): string { type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), '', diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ad7e5410da..b8aa62fe5f 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -58,7 +58,7 @@ function sourceSessionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } @@ -105,7 +105,7 @@ function targetSessionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6a98024ec1..c3cbf5b32b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -804,7 +804,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise JSON.stringify(record)), + ...packChunkRuns(session.snapshotEvents()).map(record => JSON.stringify(record)), '', ].join('\n') } @@ -849,7 +849,7 @@ async function assertReplaySession( const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { if (session.header.parentSession !== undefined) return false - const actual = session.events.flatMap((event) => { + const actual = session.snapshotEvents().flatMap((event) => { if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') return text.length === 0 ? [] : [text] diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 5a15831128..81107f1bea 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -182,7 +182,7 @@ async function waitForReply( ): Promise> { const deadline = Date.now() + timeoutMs while (true) { - const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => ( + const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => ( candidate.type === 'assistant/message' && assistantText(candidate) === text )) if (event !== undefined) return event @@ -403,7 +403,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every')) const ids = new Set(everyRecords.map(record => record.id)) - const dispatches = everyHandle.agent.session.events.filter(event => ( + const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' && ids.has(event.data.id) @@ -418,7 +418,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const decision = acceptedAt[0] if (decision === undefined) throw new Error('missing Every decision time') - const batch = everyHandle.agent.session.events.find(event => ( + const batch = everyHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'schedule' @@ -441,7 +441,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { if (reminderRequest === undefined) throw new Error('model did not receive the Every batch') expect(requestText(reminderRequest)).toContain(batchBlock.text) expectReminderFraming(reminderRequest) - const active = foldScheduleEvents(everyHandle.agent.session.events).active + const active = foldScheduleEvents(everyHandle.agent.session.snapshotEvents()).active expect(active).toHaveLength(2) expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true) @@ -463,7 +463,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { it('uses request-local browser context to create an explicit local At reminder', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at')) - const user = atHandle.agent.session.events.find(event => ( + const user = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'user' && event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT) @@ -488,12 +488,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { } expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE) - const toolCall = atHandle.agent.session.events.find(event => ( + const toolCall = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'tool/call' && event.data.name === 'schedule_create' )) if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call') expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt }) - const created = atHandle.agent.session.events.find(event => ( + const created = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'schedule/change' && event.data.operation === 'create' && event.data.schedule.kind === 'at' @@ -507,7 +507,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { prompt: AT_PROMPT, scheduledAt, }) - expect(atHandle.agent.session.events.filter(event => ( + expect(atHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' && event.data.id === schedule.id diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 368beeb9e1..a55958353d 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -516,7 +516,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - const done = agent.session.events.filter(event => event.type === 'command/done').at(-1) + const done = agent.session.snapshotEvents().filter(event => event.type === 'command/done').at(-1) if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 96673df5f8..192fa31a7a 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -134,7 +134,7 @@ describe('web e2e: settings modal and General preferences', () => { it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) - expect(existing.events.find(event => event.type === 'permission/preset')?.data) + expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) await page.getByRole('button', { name: '设置', exact: true }).click() @@ -150,11 +150,11 @@ describe('web e2e: settings modal and General preferences', () => { const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('permission:') expect(document).toContain('defaultPreset: read-only') - expect(existing.events.find(event => event.type === 'permission/preset')?.data) + expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) - expect(created.events.map(event => [event.type, event.data])).toEqual([ + expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'read-only' }], ['sandbox/mode', { mode: 'read-only' }], ['approval/policy', { policy: 'ask' }], @@ -171,7 +171,7 @@ describe('web e2e: settings modal and General preferences', () => { const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(confirmedDocument).toContain('defaultPreset: danger-full-access') const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed')) - expect(confirmed.events.map(event => [event.type, event.data])).toEqual([ + expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 6f2ca01325..24e642c83a 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -290,7 +290,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const child = scaffold.ctx.agents.get(childId) expect(child).toBeDefined() expect(child!.inbox.nextTurn).toHaveLength(2) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) // Only the waking send resumes the parked queue, FIFO, to settlement. diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 082bc0802b..54b8bd1cc6 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -158,8 +158,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(child).toBeDefined() expect(child!.status).toBe('idle') expect(child!.inbox.nextTurn).toHaveLength(1) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1) + expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + const lastEnd = child!.session.snapshotEvents().filter(event => event.type === 'turn/end').at(-1) expect((lastEnd)?.data.reason.kind).toBe('aborted') // Only an explicit waking send resumes the parked queue, FIFO, then the diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f459ae1fd9..47c6c79f0a 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 -session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 +session.md: c6c2395ba2535b1d180c82e63dc31a1ed6c1e087 +session.zh.md: df2bac57a4a847df62467ed772386eca24c02183 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f3c246f7a7..c6c2395ba2 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -377,8 +377,15 @@ declare class Session { * When this lifecycle appends the marker, it occupies this seq before the * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. - */ + */ readonly firstLiveSeq: number; + /** + * Whether construction received a replay, fork, or restored seed. + * Every seeded lifecycle has a `session/end-seed` marker after construction; + * this field distinguishes an explicitly empty seed from a fresh session + * without scanning the log. + */ + readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -399,12 +406,20 @@ declare class Session { */ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[]; + eventAt(seq: number): SessionEvent | undefined; + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[]; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number; /** @@ -451,7 +466,7 @@ declare class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -573,7 +588,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h ## Durability contract -What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bc706a11e9..df2bac57a4 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -379,8 +379,15 @@ declare class Session { * When this lifecycle appends the marker, it occupies this seq before the * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. - */ + */ readonly firstLiveSeq: number; + /** + * Whether construction received a replay, fork, or restored seed. + * Every seeded lifecycle has a `session/end-seed` marker after construction; + * this field distinguishes an explicitly empty seed from a fresh session + * without scanning the log. + */ + readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -401,12 +408,20 @@ declare class Session { */ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[]; + eventAt(seq: number): SessionEvent | undefined; + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[]; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number; /** @@ -453,7 +468,7 @@ declare class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -577,7 +592,7 @@ interface TurnEndReasonMap { ## 持久性约定 -持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 +持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 消费此约定的后端见 [persistence.md](persistence.zh.md)。 diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 99cd3695fc..7ac71f1ea0 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -71,7 +71,7 @@ describe('ACP machine permission policy', () => { const foreign = { session: { id: request.agent.session.id, - events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], + snapshotEvents: () => [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], append: () => ({}), }, } as unknown as Agent diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 85125b3661..55e56a25b0 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -882,7 +882,7 @@ describe('automation-only ACP bridge', () => { expect(secondImage.attachment.mediaType).toBe('image/jpeg') expect(secondImage.attachment.bytes).toBe(1) const agent = harness.ctx.agents.get(SessionId(sessionId)) - expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==') + expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==') }) it('rejects a malformed image batch atomically and frees the prompt slot', async () => { @@ -953,7 +953,7 @@ describe('automation-only ACP bridge', () => { sessionId, prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], })).rejects.toThrow(/inline image prompts were not advertised/) - expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) it('renders baseline resource links as textual references in the user message', async () => { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 8a44856590..c44ee3ab2e 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -216,7 +216,7 @@ describe('ACP prompt lifecycle', () => { const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) .finally(() => { settled = true }) await vi.waitFor(() => { - expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.length > 0)).toHaveLength(2) }) expect(settled).toBe(false) @@ -316,7 +316,7 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) expect(harness.adapter.requests).toEqual([]) - const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? [] + const events = harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents() ?? [] expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) }) @@ -443,7 +443,7 @@ describe('ACP prompt lifecycle', () => { await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -468,13 +468,13 @@ describe('ACP prompt lifecycle', () => { source: { kind: 'plugin', plugin: 'test' }, })) await vi.waitFor(() => { - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) await harness.client.cancel({ sessionId }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index fd95221825..352178c781 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -197,8 +197,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { scopedResult: scopedResult.value, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, - rootEvents: rootAgent.session.events.length, - scopedEvents: scopedAgent.session.events.length, + rootEvents: rootAgent.session.snapshotEvents().length, + scopedEvents: scopedAgent.session.snapshotEvents().length, } await client.fiber.dispose() diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..efbf02d60f 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -484,7 +484,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return { id: attached.id, header: attached.header, events: [...attached.events] } + return { id: attached.id, header: attached.header, events: [...attached.snapshotEvents()] } } const inspected = await inspectApiSession(this.ctx, sessionId) return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index d78509e49b..1a8fa061b6 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -112,7 +112,7 @@ export class SessionHistoryController { // Constructor seed events have no session/event notification. Normally // only the end-seed suffix is new; if persistence advanced after the // opening observation, replay everything beyond that snapshot cursor. - const suffix = session.events.slice(snapshotCursor === undefined + const suffix = session.snapshotEvents(snapshotCursor === undefined ? session.firstLiveSeq : snapshotCursor + 1) buffered.unshift(...suffix) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 342dd977eb..b18d013711 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -194,7 +194,7 @@ export class SessionController extends TypertRemoteService { ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return Promise.resolve({ meta: attached.header, events: [...attached.events] }) + return Promise.resolve({ meta: attached.header, events: [...attached.snapshotEvents()] }) } return inspectApiSession(this.ctx, sessionId, signal) } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 3f4e721228..c5c46aa0e8 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -378,7 +378,13 @@ describe('ApiSession create or adoption', () => { } as never) const resumed = { id: meta.id, - session: { id: meta.id, header: meta, events }, + session: { + id: meta.id, + header: meta, + snapshotEvents: () => events, + eventAt: (seq: number) => events[seq], + seq: events.length, + }, status: 'idle', ctx, } as unknown as Agent diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7156ac569d..07d08d1a3c 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -326,7 +326,7 @@ describe('attached updatedAt tracks human prompts', () => { meta: { cwd: '/proj', createdAt: 500 }, }) ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) - const boundary = resumed.events.at(-1) + const boundary = resumed.snapshotEvents().at(-1) expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 06e7b5a3dd..c7fad68546 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -92,7 +92,7 @@ describe('sessions.fork', () => { expect(response.ok).toBe(true) if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) - expect(child?.events.map(event => event.type)).toEqual([ + expect(child?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) expect(child?.header.parentSession).toBe(source.id) @@ -198,13 +198,13 @@ describe('sessions.fork', () => { const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { - expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type)) + expect(ctx.sessions.get(omitted.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 })) expect(pastEnd.ok).toBe(true) if (pastEnd.ok) { - expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type)) + expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } await ctx.fiber.dispose() @@ -227,11 +227,11 @@ describe('sessions.fork', () => { const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') // What a stopped message's fork button anchors on: the frozen node sits // one event before its turn/end, floored client-side to that event's seq. - const anchor = (source.events.at(-1)?.seq ?? 0) - 1 + const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.ok).toBe(true) if (!response.ok) return - expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([ + expect(ctx.sessions.get(response.value.sessionId)?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'turn/start', 'user/message', 'turn/end', 'session/end-seed', @@ -242,7 +242,7 @@ describe('sessions.fork', () => { it('rejects an in-log anchor whose turn is still open', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-open', 1, 'open') - const anchor = source.events.at(-1)?.seq ?? 0 + const anchor = source.snapshotEvents().at(-1)?.seq ?? 0 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ ok: false, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index d42639922e..90b5851e08 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -146,7 +146,7 @@ describe('Session history raw journal', () => { value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } }, }) - const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => { throw new Error('live result rescanned Session history') }) try { @@ -366,7 +366,7 @@ describe('Session history raw journal', () => { await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'event', event: { type: 'turn/end' } }, }) - const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => { throw new Error('live result rescanned Session history') }) try { diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 8a31e9208a..db04b3a6a2 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -318,7 +318,7 @@ describe('session.history projections block', () => { expect('test/last-user' in after.projections.values).toBe(false) expect(after.projections.values.sessionListMetadata).toEqual({ blank: true, - lastPromptAt: session.events.at(-1)?.time, + lastPromptAt: session.eventAt(session.seq - 1)?.time, }) }) @@ -352,7 +352,7 @@ describe('session.list projections column', () => { expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) expect(row?.projections?.values.sessionListMetadata).toEqual({ blank: false, - lastPromptAt: session.events.at(-1)?.time, + lastPromptAt: session.eventAt(session.seq - 1)?.time, }) expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index b1c54602e5..4a98ce2626 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -76,7 +76,7 @@ describe('sessions.rename', () => { expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') - const event = source.events.findLast(item => item.type === 'session/title') + const event = source.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.seq).toBe(renamed.value.seq) expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) }) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 8461d99a24..df97c60953 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -33,6 +33,16 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent { } as SessionEvent } +function eventSession(header: SessionHeader, events: readonly SessionEvent[]): Session { + return { + id: header.id, + header, + seq: events.length, + eventAt: (seq: number) => events[seq], + snapshotEvents: (fromSeq = 0, toSeqExclusive = events.length) => events.slice(fromSeq, toSeqExclusive), + } as unknown as Session +} + function cold( ctx: Context, header: SessionHeader, @@ -163,12 +173,10 @@ describe('SessionHistoryController', () => { [Symbol.asyncIterator]() const opening = iterator.next() - ctx.emit('session/event', { - id: SessionId('unrelated'), events: [event('fixture/other', 0)], - } as unknown as Session, event('fixture/other', 0)) - ctx.emit('session/event', { - id: sessionId, events: [event('fixture/start', 0)], - } as unknown as Session, event('fixture/start', 0)) + const unrelated = event('fixture/other', 0) + const start = event('fixture/start', 0) + ctx.emit('session/event', eventSession({ ...header, id: SessionId('unrelated') }, [unrelated]), unrelated) + ctx.emit('session/event', eventSession(header, [start]), start) inspected.resolve({ meta: header, events: [event('fixture/start', 0)] }) await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -194,7 +202,7 @@ describe('SessionHistoryController', () => { observed.resolve({ source: 'live', header: attached.header, - events: attached.events, + events: attached.snapshotEvents(), cursor: attached.seq - 1, projections: { asOfSeq: attached.seq - 1, values: {} }, retain: vi.fn(), @@ -292,10 +300,7 @@ describe('SessionHistoryController', () => { await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) const skipped = event('fixture/skipped', 1) const gap = event('fixture/gap', 2) - live.ctx.emit('session/event', { - id: session.id, - events: [event('fixture/start', 0), skipped, gap], - } as unknown as Session, gap) + live.ctx.emit('session/event', eventSession(session.header, [event('fixture/start', 0), skipped, gap]), gap) await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) }) diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index b5b2839b00..417c66510a 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -16,7 +16,7 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' // Empty type imports carry the loader Context merge for the settlement await // and the cmdline Context merge for the appExit host value. import type {} from '@deepseek-ai/cordis-plugin-loader' @@ -59,12 +59,16 @@ export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stde } /** Aggregate the last assistant text and turn outcome in one owned interval. */ -function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome { +function summarize(session: Session, firstSeq: number): RunOutcome { let started = false let text = '' let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined - for (const event of events) { - if (event.seq < firstSeq) continue + const length = session.seq + for (let seq = firstSeq; seq < length; seq++) { + const event = session.eventAt(seq) + if (event === undefined) { + throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) + } if (event.type === 'turn/start') { started = true continue @@ -196,7 +200,7 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { stopReasoning() } await sessions.flush(agent.session) - const outcome = summarize(agent.session.events, firstSeq) + const outcome = summarize(agent.session, firstSeq) io.stdout.write(outcome.text + '\n') if (outcome.reason?.kind === 'error') { io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`) diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 97229590b7..7cc9d3331e 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -121,7 +121,7 @@ function expectLastLifecycle( args: string, outcome: CommandResult, ): string { - const lifecycle = test.agent.session.events + const lifecycle = test.agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .slice(-2) const runEvent = lifecycle[0] diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index 95358b0036..e4cbedd42a 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -130,7 +130,7 @@ describe('command-compact real Loader composition', () => { text: 'Compacted 3 history items (~99 tokens).', sourceEventSeq: RESULT.summarySeq, }) - expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ + expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([ { type: 'command/run', data: { diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index f5fba599a4..b609f949c8 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -162,7 +162,7 @@ export async function compactSurfaceRegion( ): Promise { if (options.owner === null) signal?.throwIfAborted() const selection = validateSurfaceRegion(session, start, end) - const entryState = inspectCompactionEntryState(session.events) + const entryState = inspectCompactionEntryState(session) assertCompactionInactive( entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, @@ -305,7 +305,7 @@ function assertCompactionInactive( * @param stage - operation label included in the busy diagnostic. */ export function assertNoActiveCompaction(session: Session, stage: string): void { - const entryState = inspectCompactionEntryState(session.events) + const entryState = inspectCompactionEntryState(session) assertCompactionInactive( entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, @@ -510,11 +510,10 @@ function buildSummarizationInput( shadowedSeqs: readonly number[], ): SummarizationInput { const header = session.requestHeader() - const events = session.events const regionMessages = shadowedSeqs // shadowedSeqs are current surface seqs, so each is a valid log index. // oxlint-disable-next-line typescript/no-non-null-assertion - .map(seq => session.deriveEventMessage(events[seq]!)) + .map(seq => session.deriveEventMessage(session.eventAt(seq)!)) .filter((message): message is Message => message !== null) return { ...header?.system === undefined ? {} : { system: header.system }, @@ -524,15 +523,15 @@ function buildSummarizationInput( } /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */ -function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState { +function inspectCompactionEntryState(session: Session): CompactionEntryState { let openTurn: number | null = null let openTurnStateKnown = false let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined let compactionEntryStateKnown = false let latestEndSeedSeq: number | undefined - for (let index = events.length - 1; index >= 0; index -= 1) { + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! + const event = session.eventAt(seq)! if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') { latestEndSeedSeq = event.seq } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 82eadaa71e..89e62c0db5 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -609,7 +609,7 @@ describe('pressure measurement and retention', () => { await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() expect(session.surface.replaceGeneration).toBe(generation) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('does nothing below threshold and compacts a priced head above threshold', async () => { @@ -837,10 +837,10 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - const original = session.events.find(event => event.type === 'tool/result') + const original = session.snapshotEvents().find(event => event.type === 'tool/result') expect(original?.type === 'tool/result' && original.data.message.content[0].content[0]) .toEqual({ type: 'text', text: 'X'.repeat(3_000) }) - expect(session.events.filter(event => + expect(session.snapshotEvents().filter(event => event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) }) }) @@ -866,7 +866,7 @@ describe('compaction region transaction', () => { expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') - const summary = session.events.findLast(event => event.type === 'compaction/summary') + const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, shadowedTokenCount: result.shadowedTokenCount, @@ -882,7 +882,7 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - const replay = Session.create(SessionId('replay'), [...session.events]) + const replay = Session.create(SessionId('replay'), [...session.snapshotEvents()]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) @@ -1012,7 +1012,7 @@ describe('compaction region transaction', () => { agent(session, MODEL), )).rejects.toThrow('summary unavailable') expect(session.surface.nodes).toEqual(before) - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable' }) }) @@ -1026,7 +1026,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toBe('plain failure') - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'plain failure' }) }) @@ -1046,7 +1046,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) }) it('rejects concurrent surface appends before committing the replacement', async () => { @@ -1065,7 +1065,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/session surface changed/) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('rejects a non-shrinking framed summary under the conversation meter', async () => { @@ -1082,7 +1082,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/summary is not smaller/) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('lets a model-independent custom summarizer compact without a conversation model', async () => { @@ -1333,7 +1333,7 @@ describe('default one-shot summarizer', () => { const session = conversation(3, 'large history '.repeat(500)) const nodes = session.surface.nodes await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL) - expect(session.events.findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], llmStreamCall: true, provider: 'routed-summary-provider', @@ -1459,7 +1459,7 @@ describe('automatic listener and loader composition', () => { next: () => Promise = () => Promise.resolve(undefined), ): Promise { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } - const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 + const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal }, @@ -1479,11 +1479,11 @@ describe('automatic listener and loader composition', () => { }) const pressured = conversation(4) await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) - expect(pressured.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) const small = conversation(1) await preStep(ctx, agent(small, MODEL)) - expect(small.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) @@ -1500,7 +1500,7 @@ describe('automatic listener and loader composition', () => { .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() - expect(pressured.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('warns and continues after operational failures, including non-Errors', async () => { @@ -1516,7 +1516,7 @@ describe('automatic listener and loader composition', () => { await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] }) expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('warns once per routed target when proactive pressure has no context metadata', async () => { @@ -1575,7 +1575,7 @@ describe('automatic listener and loader composition', () => { expect(decision).toBe(true) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) }) @@ -1594,7 +1594,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) expect(compact.calls).toHaveLength(0) }) @@ -1612,7 +1612,7 @@ describe('automatic listener and loader composition', () => { const session = toolConversation() expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(compact.calls).toHaveLength(1) expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) @@ -1635,8 +1635,8 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable after prune' }) expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface')) }) @@ -1838,10 +1838,10 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - const summaries = session.events.filter(event => event.type === 'compaction/summary').length + const summaries = session.snapshotEvents().filter(event => event.type === 'compaction/summary').length expect(summaries).toBe(1) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) - expect(session.events.filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) + expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) }) it('auto:false installs neither automatic listener', async () => { @@ -1853,7 +1853,7 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -1885,7 +1885,7 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) }) @@ -2025,7 +2025,7 @@ describe('route-priced image pressure', () => { const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL) expect(result).not.toBeNull() - const summaryEvent = session.events.find(event => event.type === 'compaction/summary') + const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summaryEvent).toBeDefined() const shadowedHeuristic = before.nodes .filter(node => result?.shadowedSeqs.includes(node.seq)) diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 1dacfe2114..7d44f6e268 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -215,7 +215,7 @@ function overflowHistorySeed(): SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - return [...session.events] + return [...session.snapshotEvents()] } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { @@ -233,8 +233,8 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') - expect(agent.session.events.some(event => event.type === 'compaction/summary')).toBe(true) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -250,7 +250,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() const precedingResult = events.findLast(event => @@ -282,7 +282,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( (e): e is SurfaceEvent => @@ -356,7 +356,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, )! @@ -419,11 +419,11 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data)) .toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) - expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) .toEqual([3]) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index a0497f8041..d5d958b3fc 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -234,8 +234,8 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus return { ctx, compact: new GatedCompactionEngine(ctx, { auto: false }), flushes: () => flushes } } -function compactEvents(session: Session): Array { - return session.events.filter(event => event.type.startsWith('compaction/')) +function compactEvents(session: Session): SessionEvent[] { + return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')) } describe('compactNow through the real loop', () => { @@ -296,14 +296,14 @@ describe('compactNow through the real loop', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - const start = agent.session.events.findLast(event => event.type === 'compaction/start') + const start = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/start') const injected = agent.inbox.nextStep.find(message => message.source.kind === 'plugin' && message.source.plugin === 'test') - const end = agent.session.events.findLast(event => event.type === 'compaction/end') + const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(injected).toBeDefined() expect(end).toBeDefined() - expect(agent.session.events.some(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.id === injected?.id)).toBe(false) agent.followup(createUserMessage({ @@ -335,7 +335,7 @@ describe('compactNow through the real loop', () => { expect(attempts).toEqual(['compaction/start', 'compaction/summary']) expect(result).not.toBeNull() expect(derivedText(agent.session)[0]).toContain('checkpoint') - expect(agent.session.events.filter(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0) const types = compactEvents(agent.session).map(event => event.type) expect(types).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) @@ -355,7 +355,7 @@ describe('compactNow through the real loop', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('releases turn admission after a summarizer failure and records the failed attempt', async () => { @@ -405,14 +405,14 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) - expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - const start = session.events.findLast(event => event.type === 'compaction/start') - const summaryEvent = session.events.findLast(event => event.type === 'compaction/summary') - const checkpoint = session.events.findLast( + expect(session.snapshotEvents().filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const summaryEvent = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') + const checkpoint = session.snapshotEvents().findLast( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && isCompactCheckpointSource(event.data.source), ) - const end = session.events.findLast(event => event.type === 'compaction/end') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } expect(start?.data).toEqual({ ...correlated, turn: null }) expect(summaryEvent?.data.sourceCommandId).toBe(commandId) @@ -442,9 +442,9 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) - const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) - const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') - const orphan = reloaded.events.find(event => event.type === 'compaction/start') + const reloaded = Session.create(SessionId('stale-orphan'), [...original.snapshotEvents()]) + const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') + const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER) @@ -461,7 +461,7 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) + const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.snapshotEvents()]) const agent = fakeAgent(reloaded, () => () => undefined) await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() @@ -564,7 +564,7 @@ describe('compactNow transaction and failure classification', () => { expect(session.surface.replaceGeneration).toBe(generation + 1) expect(session.surface.nodes).not.toContain(head) expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) - expect(session.events.some(event => event.type === 'user/message' + expect(session.snapshotEvents().some(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source))).toBe(false) }) @@ -583,7 +583,7 @@ describe('compactNow transaction and failure classification', () => { expect(causeOf(error).message).toBe('boundary rejected') vi.restoreAllMocks() expect(flushes()).toBe(0) - expect(session.events.findLast(event => event.type.startsWith('compaction/'))?.type) + expect(session.snapshotEvents().findLast(event => event.type.startsWith('compaction/'))?.type) .toBe('compaction/summary') expect(compactEvents(session).filter(event => event.type === 'compaction/start')).toHaveLength(1) @@ -649,7 +649,7 @@ describe('compactNow transaction and failure classification', () => { vi.restoreAllMocks() expect(error.code).toBe('commit') expect(released).toBe(1) - const end = session.events.findLast(event => event.type === 'compaction/end') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.type === 'compaction/end' && end.data.error).toContain('summary record rejected') expect(end?.type === 'compaction/end' && end.data.turn).toBeNull() }) @@ -685,8 +685,8 @@ describe('compactNow transaction and failure classification', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - expect(session.events.some(event => event.type === 'turn/start')).toBe(false) - expect(session.events.find(event => event.type === 'compaction/start')?.data) + expect(session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) + expect(session.snapshotEvents().find(event => event.type === 'compaction/start')?.data) .toEqual({ compactionId: result?.compactionId, turn: null }) }) @@ -698,9 +698,9 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) - const start = session.events.findLast(event => event.type === 'compaction/start') - const end = session.events.findLast(event => event.type === 'compaction/end') + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) @@ -716,7 +716,7 @@ describe('compactNow transaction and failure classification', () => { const reserve = vi.fn(() => testCase.release) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const agent = fakeAgent(testCase.session, reserve) - const before = [...testCase.session.events] + const before = [...testCase.session.snapshotEvents()] const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() controller.abort(reason) @@ -731,7 +731,7 @@ describe('compactNow transaction and failure classification', () => { expect(reserve).not.toHaveBeenCalled() expect(measure).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) - expect(testCase.session.events).toEqual(before) + expect(testCase.session.snapshotEvents()).toEqual(before) vi.restoreAllMocks() } }) @@ -780,7 +780,7 @@ describe('compactNow transaction and failure classification', () => { await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('waits for the durability checkpoint before cancellation wins and admission releases', async () => { @@ -824,7 +824,7 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - const summary = session.events.find(event => event.type === 'compaction/summary') + const summary = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summary?.type === 'compaction/summary' && summary.data.rawOutput).toEqual(compact.rawOutput) expect(summary?.type === 'compaction/summary' && summary.data.usage).toEqual(compact.usage) }) @@ -839,8 +839,8 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - const start = session.events.findLast(event => event.type === 'compaction/start') - const end = session.events.findLast(event => event.type === 'compaction/end') + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(end).toBeDefined() expect(end!.time - start!.time).toBeGreaterThan(0) diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index 7e261e15fa..d3f3212c1e 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -136,7 +136,7 @@ export class ToolResultPruner extends Service { pruneSession(session: Session): PruneResult { const candidates: SnapshotCandidate[] = [] for (const seq of [...session.surface.nodes]) { - const event = session.events[seq] + const event = session.eventAt(seq) /* v8 ignore next -- surface seqs are validated contiguous log references. */ if (event?.type === 'tool/result') candidates.push({ seq, event }) } diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index e21a3635a7..1235b8fa2c 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -185,8 +185,8 @@ describe('ToolResultPruner session transaction', () => { expect(entry).toMatchObject({ originalSeq, callId: ToolCallId('one'), charsBefore: 100 }) expect(entry.charsAfter).toBeLessThanOrEqual(50) - const original = session.events[originalSeq]! - const replacement = session.events[entry.replacementSeq]! as SurfaceEvent + const original = session.snapshotEvents()[originalSeq]! + const replacement = session.snapshotEvents()[entry.replacementSeq]! as SurfaceEvent expect(original).toMatchObject({ type: 'tool/result', data: { @@ -219,7 +219,7 @@ describe('ToolResultPruner session transaction', () => { // Shadow-price protocol: the metering event sits directly before the // replacement and prices the shadowed node with the shared estimator. if (original.type !== 'tool/result') throw new Error('original is not a tool/result') - expect(session.events[entry.replacementSeq - 1]).toMatchObject({ + expect(session.snapshotEvents()[entry.replacementSeq - 1]).toMatchObject({ type: 'compaction/prune', data: { shadowedRange: { start: originalSeq, end: originalSeq }, @@ -254,7 +254,7 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) - const replay = Session.create(session.id, [...session.events]) + const replay = Session.create(session.id, [...session.snapshotEvents()]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) }) diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index 7252fb06b0..df82f348ee 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -251,8 +251,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): SessionTrace => { const trace: SessionTrace = { openTurn: null, compaction: undefined } traces.set(session, trace) - const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events) - for (const event of session.events) { + const events = session.snapshotEvents() + const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events) + for (const event of events) { // Constructor-seed repair boundaries can precede the end-seed marker // that proves an inherited orphan stale. Replay that inherited prefix // without letting the soon-to-be-cleared bracket veto its repair. diff --git a/packages/compaction/compaction/src/tool-pairing.ts b/packages/compaction/compaction/src/tool-pairing.ts index 72ebb4dad6..c492d53a70 100644 --- a/packages/compaction/compaction/src/tool-pairing.ts +++ b/packages/compaction/compaction/src/tool-pairing.ts @@ -37,15 +37,6 @@ function eventDelta(event: SessionEvent): number { } } -/** Read and validate the event named by a surface sequence. */ -function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent { - const event = events[seq] - if (event === undefined || event.seq !== seq) { - throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) - } - return event -} - /** Fold surface sequences not yet in the cache into its balance state. */ function extendCache( session: Session, @@ -56,11 +47,14 @@ function extendCache( const tail = seqs.slice(processed) // Validate the unseen tail before mutating the live cache, so a corrupt // append cannot leave a partially advanced state behind. - const events = session.events const pendingCuts: boolean[] = [] let inProgressToolCalls = cache.inProgressToolCalls for (const seq of tail) { - inProgressToolCalls += eventDelta(eventForSeq(events, seq)) + const event = session.eventAt(seq) + if (event === undefined || event.seq !== seq) { + throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) + } + inProgressToolCalls += eventDelta(event) if (inProgressToolCalls < 0) { throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`) } diff --git a/packages/compaction/compaction/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts index 220dff5648..8b02f5f811 100644 --- a/packages/compaction/compaction/tests/compaction.spec.ts +++ b/packages/compaction/compaction/tests/compaction.spec.ts @@ -130,7 +130,7 @@ describe('CompactionEngine seam', () => { const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm')) - const startEvent = session.events.find(e => e.type === 'compaction/start') + const startEvent = session.snapshotEvents().find(e => e.type === 'compaction/start') expect(startEvent).toBeDefined() // Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType); // verify the runtime value is absent. @@ -141,13 +141,13 @@ describe('CompactionEngine seam', () => { expect(result.endSeq).toBeGreaterThan(result.summarySeq) expect(result.shadowedRange).toEqual({ start: original.seq, end: original.seq }) expect(result.shadowedSeqs).toEqual([original.seq]) - const checkpoint = session.events.find(event => event.type === 'user/message' + const checkpoint = session.snapshotEvents().find(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source)) expect(checkpoint?.type === 'user/message' && checkpoint.data.source) .toEqual(compactCheckpointSource(result.compactionId)) expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false) expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false) - expect(session.events.filter(e => e.type.startsWith('compaction/')).map(e => e.type)) + expect(session.snapshotEvents().filter(e => e.type.startsWith('compaction/')).map(e => e.type)) .toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) }) diff --git a/packages/compaction/compaction/tests/invariant.spec.ts b/packages/compaction/compaction/tests/invariant.spec.ts index 9a1165ab57..1aa8682e6a 100644 --- a/packages/compaction/compaction/tests/invariant.spec.ts +++ b/packages/compaction/compaction/tests/invariant.spec.ts @@ -68,9 +68,9 @@ describe('compaction invariants', () => { const source = Session.create(SessionId('stale-compaction-source')) source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)) + expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['compaction/start', 'session/end-seed']) await ctx.plugin(InvariantRegistry) @@ -89,9 +89,9 @@ describe('compaction invariants', () => { startTurn(source) source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)) + expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'compaction/start', 'session/end-seed']) await ctx.plugin(InvariantRegistry) @@ -111,9 +111,9 @@ describe('compaction invariants', () => { startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)).toEqual([ + expect(replayed.snapshotEvents().map(event => event.type)).toEqual([ 'compaction/start', 'turn/start', 'turn/end', @@ -138,9 +138,9 @@ describe('compaction invariants', () => { source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) source.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' }) const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.at(-1)?.type).toBe('session/end-seed') + expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') await ctx.plugin(InvariantRegistry) await expect(ctx.plugin(CompactionInvariant).then(() => undefined)) diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts index cd4e2a874f..8478f47edd 100644 --- a/packages/compaction/compaction/tests/tool-pairing.spec.ts +++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts @@ -7,7 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' const SURFACE = { surfaceOp: 'append' as const } function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number { - return session.events.filter(event => event.type === type)[nth]!.seq + return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq } function surfaceSeq(session: Session, seq: number): number { @@ -241,41 +241,31 @@ describe('tool-pairing cache refresh', () => { ] const nodes: number[] = [0, 1, 2] let generation = 0 - let eventCollectionReads = 0 - let eventIndexReads = 0 - const trackedEvents = new Proxy(events, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1 - return Reflect.get(target, property, receiver) as unknown - }, - }) + let eventReads = 0 const surface = { get nodes() { return nodes }, get replaceGeneration() { return generation }, } const session = { surface, - get events() { - eventCollectionReads += 1 - return trackedEvents + eventAt(seq: number) { + eventReads += 1 + return events[seq] }, } as unknown as Session expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true) expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) events.push({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) events.push({ type: 'user/message', seq: 4, time: 4, @@ -286,8 +276,7 @@ describe('tool-pairing cache refresh', () => { }) nodes.push(4) expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true) - expect(eventCollectionReads).toBe(2) - expect(eventIndexReads).toBe(4) + expect(eventReads).toBe(4) events.push( { @@ -321,8 +310,7 @@ describe('tool-pairing cache refresh', () => { ) nodes.push(5, 6) expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true) - expect(eventCollectionReads).toBe(3) - expect(eventIndexReads).toBe(6) + expect(eventReads).toBe(6) events.push({ type: 'user/message', seq: 7, time: 7, @@ -334,8 +322,7 @@ describe('tool-pairing cache refresh', () => { nodes.splice(0, nodes.length, 7) generation += 1 expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true) - expect(eventCollectionReads).toBe(4) - expect(eventIndexReads).toBe(7) + expect(eventReads).toBe(7) }) it('rebuilds defensively when a same-generation surface entry count regresses', () => { @@ -355,7 +342,7 @@ describe('tool-pairing cache refresh', () => { ] const nodes: number[] = [0, 1] const session = { - events, + eventAt: (seq: number) => events[seq], surface: { nodes, replaceGeneration: 0 }, } as unknown as Session expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true) @@ -399,24 +386,24 @@ describe('tool-pairing corrupt surfaces', () => { it('throws when a current surface seq has no matching event or indexes the wrong event', () => { const missingSeq = 1 const missing = { - events: [{ + eventAt: (seq: number) => [{ type: 'user/message', seq: 0, time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', - } satisfies SessionEvent], + } satisfies SessionEvent][seq], surface: { nodes: [missingSeq], replaceGeneration: 0 }, } as unknown as Session expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/) const mismatchedSeq = 0 const mismatched = { - events: [{ + eventAt: (seq: number) => [{ type: 'user/message', seq: 99, time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', - } satisfies SessionEvent], + } satisfies SessionEvent][seq], surface: { nodes: [mismatchedSeq], replaceGeneration: 0 }, } as unknown as Session expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/) diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index ace82c24b2..f614926241 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -53,7 +53,7 @@ function visibleBaselineSource( } } for (const seq of agent.session.surface.nodes.toReversed()) { - const event = agent.session.events[seq] + const event = agent.session.eventAt(seq) if (event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) return event.data.source @@ -228,7 +228,7 @@ export function apply(ctx: Context, config: Config): void { const alreadySupplied = desired !== undefined && ( claimed.some(message => sameContextPayload(message, desired)) || agent.session.surface.nodes.some((seq) => { - const event = agent.session.events[seq] + const event = agent.session.eventAt(seq) return event?.type === 'user/message' && sameContextPayload(event.data, desired) }) ) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index 30e700eda5..ce0118a569 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -139,7 +139,7 @@ function visibleInstructionChanges( ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.events.entries()) { + for (const [seq, event] of agent.session.snapshotEvents().entries()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 7f468bb6c8..a5cb2ae94c 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -84,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.events])).toContain(PROBE) + expect(finalText([...live.agent.session.snapshotEvents()])).toContain(PROBE) }, 120_000) it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => { @@ -96,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) + expect(finalText([...live.agent.session.snapshotEvents()])).toContain(NESTED_PROBE) }, 120_000) it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { @@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - const events = [...live.agent.session.events] + const events = [...live.agent.session.snapshotEvents()] const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 5c9c3e7f2f..9855748a73 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -246,7 +246,7 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise + return agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) @@ -1096,7 +1096,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1115,14 +1115,14 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(root, [...original.session.events]) + const firstResume = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(root, [...firstResume.session.events]) + const secondResume = stubAgent(root, [...firstResume.session.snapshotEvents()]) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) expect(baselineEvents(secondResume)).toHaveLength(1) - expect(secondResume.session.events.filter(event => event.type === 'user/message' + expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { await rm(root, { recursive: true, force: true }) @@ -1144,11 +1144,11 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - expect(resumed.session.events.filter(event => event.type === 'user/message' + expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { await ctx.fiber.dispose() @@ -1170,13 +1170,13 @@ describe('workspace context request injection', () => { const original = stubAgent(cwd) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(cwd, [...original.session.events]) + const firstResume = stubAgent(cwd, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(cwd, [...firstResume.session.events]) + const secondResume = stubAgent(cwd, [...firstResume.session.snapshotEvents()]) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) - expect(secondResume.session.events.filter(event => event.type === 'user/message' + expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md') expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root') @@ -1200,11 +1200,11 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - const resumed = stubAgent(cwd, [...original.session.events]) + const resumed = stubAgent(cwd, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - const update = resumed.session.events.findLast(event => event.type === 'user/message' + const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) expect(update?.type === 'user/message' && update.data.source.kind === 'agent-instructions' @@ -1237,7 +1237,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1256,7 +1256,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - const repeated = stubAgent(root, [...resumed.session.events]) + const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1290,7 +1290,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - const claudeResume = stubAgent(root, [...original.session.events]) + const claudeResume = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions' @@ -1305,7 +1305,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const restored = stubAgent(root, [...claudeResume.session.events]) + const restored = stubAgent(root, [...claudeResume.session.snapshotEvents()]) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions' @@ -1340,7 +1340,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1354,7 +1354,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - const repeated = stubAgent(root, [...resumed.session.events]) + const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1384,7 +1384,7 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( @@ -1400,7 +1400,7 @@ describe('workspace context request injection', () => { expect(decision.messages.map(message => message.id)).toEqual([inserted?.id]) expect(resumed.inbox.nextStep).toEqual([]) - expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(resumed.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'agent-instructions' && message.source.baseline === true))).toHaveLength(1) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1430,7 +1430,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( @@ -1483,7 +1483,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( @@ -1524,7 +1524,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const removal = agent.session.events.find(event => event.type === 'user/message' + const removal = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.changes.some(change => change.action === 'remove')) expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ @@ -1558,7 +1558,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message' + const workspaceEvents = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(workspaceEvents).toHaveLength(2) expect(workspaceEvents.some(event => event.type === 'user/message' @@ -1567,7 +1567,7 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(2) } finally { await rm(root, { recursive: true, force: true }) @@ -1790,7 +1790,7 @@ describe('workspace context request injection', () => { // The first resumed pre-step retains the compatible visible baseline and // appends only the offline file transition needed to reach current state. await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) @@ -1798,7 +1798,7 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(1) - const latest = resumed.session.events.findLast(event => + const latest = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], @@ -1979,7 +1979,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const contexts = agent.session.events.filter(event => + const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', ) expect(contexts).toHaveLength(1) @@ -2558,14 +2558,14 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })) await agent.whenIdle() - expect(agent.session.events.filter(event => + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', )).toHaveLength(0) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })) await agent.whenIdle() - const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts).toHaveLength(1) expect(adapter.requests).toHaveLength(3) expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n')) @@ -3540,7 +3540,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const resumed = stubAgent(root, [...agent.session.events]) + const resumed = stubAgent(root, [...agent.session.snapshotEvents()]) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -3574,11 +3574,11 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, resumed) - const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') + const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -4608,7 +4608,7 @@ describe('workspace context inbox synchronization', () => { callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncWorkspaceContext(ctx, original) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await ctx.tools.execute({ signal: testToolSignal, diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index c8914d3695..d2b2a205ba 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -805,7 +805,7 @@ describe('session reference discovery and preparation', () => { expect(JSON.stringify(before)).toContain('durable referenced fact') expect(JSON.stringify(before)).toContain('use @source') expect(JSON.stringify(before)).not.toContain('later source mutation') - expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) + expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(before) }) it('rejects direct invalid configuration before service publication', async () => { diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 8a1e7880c6..6649655709 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -77,12 +77,13 @@ function formatDuration(elapsedMs: number): string { /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { - const start = agent.session.events.findLastIndex( + const events = agent.session.snapshotEvents() + const start = events.findLastIndex( event => event.type === 'turn/start' && event.data.turn === turn, ) const entered = start < 0 ? [] - : agent.session.events.slice(start + 1) + : events.slice(start + 1) .flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...entered, ...proposed] } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 187e7d85ae..90ec5f2ff2 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -161,11 +161,12 @@ function validateReading( /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const [index, event] of session.events.entries()) { + const events = session.snapshotEvents() + for (const [index, event] of events.entries()) { if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue - validateReading(session.events.slice(0, index), event, fail) + validateReading(events.slice(0, index), event, fail) } } @@ -179,7 +180,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(session.events, event, fail) + validateReading(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 7f3aa63e17..f461998377 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -67,7 +67,7 @@ function openMessageTurn(session: Session, turn: number, clientTimeZone?: string function contextTexts(session: Session): string[] { const texts: string[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context') { @@ -168,7 +168,7 @@ describe('durable step context', () => { + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n' + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') // The reading is a `snapshot`-form context: one named contribution whose @@ -291,8 +291,8 @@ describe('durable step context', () => { const original = Session.create(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') - const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') + const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') if (user === undefined || reading === undefined) throw new Error('missing source surface events') original.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted history' }], @@ -304,15 +304,15 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - const resumed = Session.create(SessionId('resumed'), [...original.events]) + const resumed = Session.create(SessionId('resumed'), [...original.snapshotEvents()]) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) - const beforeSkip = resumed.events.length + const beforeSkip = resumed.snapshotEvents().length await fire(ctx, resumedAgent, 2, 1) - expect(resumed.events).toHaveLength(beforeSkip) + expect(resumed.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(resumed)).toHaveLength(1) vi.setSystemTime(BASE + 1_000) @@ -334,14 +334,14 @@ describe('durable step context', () => { vi.setSystemTime(BASE + 500) openMessageTurn(first, 2) - const beforeSkip = first.events.length + const beforeSkip = first.snapshotEvents().length await fire(ctx, firstAgent, 2, 1) const independent = Session.create(SessionId('interval-independent')) openMessageTurn(independent, 1) await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) - expect(first.events).toHaveLength(beforeSkip) + expect(first.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(first)).toHaveLength(1) expect(contextTexts(independent)).toHaveLength(1) }) @@ -460,7 +460,7 @@ describe('real agent-loop request history', () => { expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) @@ -482,9 +482,9 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - const contexts = agent.session.events.filter( + const contexts = agent.session.snapshotEvents().filter( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') - const starts = agent.session.events.filter(event => event.type === 'step/start') + const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) for (let index = 0; index < contexts.length; index += 1) { @@ -504,7 +504,7 @@ describe('real agent-loop request history', () => { expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.') for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing') - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') await ctx.fiber.dispose() }) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index af6de0f6b6..cc1c4985d3 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -121,7 +121,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tmux-context') { @@ -169,7 +169,7 @@ describe('tmux-context injection', () => { + 'window active=1, pane active=0, ' + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', ]) - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) if (event?.type !== 'user/message') throw new Error('missing tmux context') // `snapshot` form: one named contribution carrying exactly the reading the // model saw, so a consumer attributes it without re-splitting prose. diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 80fdfba8f9..b5f65362f7 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -28,7 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail('a loop-built request must carry a frozen messages array') } - const events = session.events + const events = session.snapshotEvents() if (!events.some(event => event.type === 'step/start')) { return fail('a loop-built request with no step/start in its session log') } diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index 63b353ebae..e83f0dfc9d 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -33,8 +33,8 @@ export class RuntimeContextProjection { */ constructor(ctx: Context, session: Session) { const surface = new Set(session.surface.nodes) - for (let index = session.events.length - 1; index >= 0; index -= 1) { - const event = session.events[index] + for (let index = session.seq - 1; index >= 0; index -= 1) { + const event = session.eventAt(index) if (event?.type !== 'user/message' || !isOwned(event.data)) continue this.retained ??= null if (surface.has(event.seq)) { diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 395a0333c2..bdee0222c0 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -334,7 +334,7 @@ describe('AgentLoop initiator scope', () => { }]) const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request') expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i) - const call = handle.agent.session.events.find(event => event.type === 'tool/call') + const call = handle.agent.session.snapshotEvents().find(event => event.type === 'tool/call') expect(call?.type === 'tool/call' ? call.data.arguments : undefined) .toBe(JSON.stringify({ path: '/v1/capability' })) expect(captured).toBe(handle.agent) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index fc39e46a28..9507844521 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -35,7 +35,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) - expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced']) + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced']) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) await agent.whenIdle() @@ -47,7 +47,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) - const injected = agent.session.events.at(-1) + const injected = agent.session.snapshotEvents().at(-1) expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source) .toEqual({ kind: 'plugin', plugin: '' }) }) @@ -97,7 +97,7 @@ describe('Agent', () => { expect(() => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })) }).toThrow(/non-JSON-serializable/) - expect(agent.session.events).toHaveLength(0) + expect(agent.session.snapshotEvents()).toHaveLength(0) }) it('steer() while idle becomes a woken prompt turn', async () => { @@ -108,7 +108,7 @@ describe('Agent', () => { agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() - expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message')).toBe(true) expect(adapter.requests).toHaveLength(1) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4abaf954b8..4c148d663d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -71,7 +71,7 @@ describe('Agent.cancel()', () => { // The prompt ran: its user message is in the log and one turn completed. expect(userTexts(agent)).toEqual(['real prompt']) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) }) it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => { @@ -86,13 +86,13 @@ describe('Agent.cancel()', () => { // A waking send starts and claims synchronously, so keepInbox has no // pending item to preserve by the time this cancellation runs. agent.cancel({ kind: 'user' }, { keepInbox: true }) - expect(agent.session.events.some(event => + expect(agent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false) await agent.whenIdle() expect(agent.inbox.nextTurn).toHaveLength(0) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) const idle = waitForIdle(ctx, agent) @@ -146,7 +146,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['active', 'B']) expect(adapter.requests).toHaveLength(2) expect(agent.inbox.nextTurn).toHaveLength(0) - expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/end').map(e => e.type === 'turn/end' ? e.data.reason : null)).toEqual([ { kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }, @@ -196,7 +196,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') // No replay with nothing to run: the latched message is gone, so no // empty follow-up turn is recorded. - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(1) }) it('latches a wake arriving deep into a slow abort convergence', async () => { @@ -255,9 +255,9 @@ describe('Agent.cancel()', () => { await new Promise(r => setTimeout(r, 30)) expect(userTexts(agent)).toEqual([]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) expect(agent.status).toBe('idle') }) @@ -286,7 +286,7 @@ describe('Agent.cancel()', () => { await driverDone(agent) expect(agent.status).toBe('idle') - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) }) @@ -323,7 +323,7 @@ describe('Agent.cancel()', () => { replacementObservation = agent.whenIdle().then(() => ({ status: agent.status, requests: adapter.requests.length, - turns: agent.session.events.filter(event => event.type === 'turn/start').length, + turns: agent.session.snapshotEvents().filter(event => event.type === 'turn/start').length, })) agent.cancel({ kind: 'user' }) replacementRegistered.resolve(undefined) @@ -400,7 +400,7 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) expect(userTexts(agent)).toEqual(['go']) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) @@ -436,8 +436,8 @@ describe('Agent.cancel()', () => { expect(executions).toBe(0) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - const call = agent.session.events.find(event => event.type === 'tool/call') - const result = agent.session.events.find(event => event.type === 'tool/result') + const call = agent.session.snapshotEvents().find(event => event.type === 'tool/call') + const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result') expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ message: { @@ -477,7 +477,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toContain('second') // The second turn completed (its reply was streamed). - const reasons = agent.session.events.filter(e => e.type === 'turn/end') + const reasons = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(reasons.length).toBe(2) }) @@ -494,13 +494,13 @@ describe('Agent.cancel()', () => { // The prefix the user watched stream is committed as the step's message, // carrying the truncation marker and citing exactly the chunk events that // delivered it. - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'partial' }]) expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) - const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) + const chunkSeqs = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk').map(e => e.seq) expect(message?.sourceEventSeqs).toEqual(chunkSeqs) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end')) expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end')) @@ -530,7 +530,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'reasoning', text: 'thinking about it' }]) // A usage chunk delivered before the cancel travels with the finalized prefix. @@ -557,10 +557,10 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The undispatched call is dropped whole — no dangling tool_use to pair. - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'reading the file' }]) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) }) it('cancel during error recovery does not finalize the failed stream', async () => { @@ -582,8 +582,8 @@ describe('Agent.cancel()', () => { // The failed stream's prefix stays off the surface: clients reset it on // retry, and provider failures commit nothing. - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) - const end = agent.session.events.find(e => e.type === 'turn/end') + expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) + const end = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') }) @@ -603,14 +603,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const messages = agent.session.events.filter(e => e.type === 'assistant/message') + const messages = agent.session.snapshotEvents().filter(e => e.type === 'assistant/message') expect(messages).toHaveLength(1) const message = messages[0]! expect(message.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'recovered' }]) expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined() // The abandoned attempt's chunks stay out of the completion's source set. - const doomedSeqs = agent.session.events + const doomedSeqs = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/chunk' && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial') .map(e => e.seq) @@ -633,7 +633,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) }) it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { @@ -662,7 +662,7 @@ describe('Agent.cancel()', () => { // log is balanced (the open step was closed by the cancel branch). expect(streamed).toBe(false) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -697,8 +697,8 @@ describe('Agent.cancel()', () => { expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false) - const types = agent.session.events.map(e => e.type) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(false) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -751,7 +751,7 @@ describe('Agent.cancel()', () => { // No turn opened, no step streamed, and a later prompt still runs (the marker // was reset). expect(streamed).toBe(false) - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(false) }) it('a running-listener cancellation replays replacement work at convergence', async () => { @@ -782,7 +782,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) }) it('a prompt queued during pre-step cancellation replays at convergence', async () => { @@ -805,7 +805,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(3) }) it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { @@ -826,10 +826,10 @@ describe('Agent.cancel()', () => { // started from the dropped steering. await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('idle') - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts.length).toBe(1) // only the original (cancelled) turn // The steering text was dropped — it never reached the log. - const flat = agent.session.events + const flat = agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.data.content) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -860,7 +860,7 @@ describe('Agent.cancel()', () => { status: agent.status, requests: adapter.requests.length, users: userTexts(agent), - events: agent.session.events.map(event => event.type), + events: agent.session.snapshotEvents().map(event => event.type), })}`)) }, 1000) }), @@ -871,7 +871,7 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(2) expect(userTexts(agent)).toEqual(['original', 'replacement']) expect(agent.inbox.nextTurn).toHaveLength(0) - const reasons = agent.session.events + const reasons = agent.session.snapshotEvents() .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) @@ -898,7 +898,7 @@ describe('Agent.cancel()', () => { const runtimeReason: unknown = adapter.requests[0]?.signal?.reason expect(runtimeReason).toEqual({ kind: 'parent' }) expect(runtimeReason).toBe(supplied) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' }, @@ -919,7 +919,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await handle.dispose() - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -990,7 +990,7 @@ describe('Agent.cancel()', () => { const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index e01fd73b01..489fd644e2 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -73,7 +73,7 @@ describe('assistant replay provider and model fields', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const recorded = agent.session.events.find(event => event.type === 'assistant/message') + const recorded = agent.session.snapshotEvents().find(event => event.type === 'assistant/message') expect(recorded?.type === 'assistant/message' && recorded.data.message.source).toEqual({ kind: 'model', provider: 'mock', model: 'next-model', replayState, }) @@ -112,7 +112,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') || event.type === 'step/end' || event.type === 'turn/end') @@ -125,7 +125,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'wake') await idle - expect(agent.session.events + expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) @@ -175,7 +175,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] expect(events .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -200,10 +200,10 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start' + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start' || event.type === 'step/start' || event.type === 'turn/end').map(event => event.type)) .toEqual(['turn/start', 'turn/end']) - expect(agent.session.events.find(event => event.type === 'turn/end')?.data) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data) .toEqual({ turn: 1, reason: { kind: 'completed' } }) expect(agent.inbox.nextTurn).toHaveLength(0) }) @@ -244,16 +244,16 @@ describe('abort during tool execution ends the turn', () => { await started.promise await fiber.dispose() - expect(agent.session.events + expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) .toEqual([]) expect(agent.inbox.nextStep.map(inboxText)) .toEqual(['accepted result context during disposal']) - expect(agent.session.events.filter(event => event.type === 'turn/start')) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')) .toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) @@ -307,7 +307,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])[0]) @@ -359,7 +359,7 @@ describe('plugin exceptions are contained', () => { send(agent, 'first') await waitForIdle(ctx, agent) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } }, }) @@ -395,8 +395,8 @@ describe('disposal leaves the two-state status contract balanced', () => { expect(statuses).toEqual(['running', 'idle']) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - const messages = agent.session.events + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .flatMap(event => event.data.content) .flatMap(block => block.type === 'text' ? [block.text] : []) @@ -445,7 +445,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message : undefined).toContain('has no provider/model') @@ -507,7 +507,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { ['content', 'id', 'role', 'source'], ]) expect(targets).toEqual(['next-turn', 'next-step']) - const steeringSources = agent.session.events.flatMap(e => + const steeringSources = agent.session.snapshotEvents().flatMap(e => e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) @@ -534,7 +534,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.snapshotEvents()] }) const forked = new ReactLoopAgent( ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) @@ -603,7 +603,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () expect(errors[0]).toBeInstanceOf(LlmError) expect((errors[0] as LlmError).failure).toEqual(failure) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) // A failed step must not synthesize an assistant message. @@ -625,7 +625,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }]) - expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) + expect([...agent.session.snapshotEvents()].some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { @@ -647,7 +647,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () }) describe('step boundary publication order', () => { - it('the step/start event is in session.events when its session/event listener fires', async () => { + it('the step/start event is in session.snapshotEvents() when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) @@ -655,7 +655,7 @@ describe('step boundary publication order', () => { const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/start') return - const events = [...subject.events] + const events = [...subject.snapshotEvents()] const last = events.at(-1) observed.push({ turn: event.data.turn, @@ -691,7 +691,7 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] return { turnStart: e.filter(x => x.type === 'turn/start').length, turnEnd: e.filter(x => x.type === 'turn/end').length, @@ -721,7 +721,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) expect(errors).toEqual([]) @@ -754,7 +754,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'rejected') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'turn/start' + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start' || event.type === 'user/message')).toBe(false) expect(agent.inbox.nextTurn).toHaveLength(1) expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit']) @@ -785,7 +785,7 @@ describe('turn and step boundary recovery', () => { stepEnd: 0, errors: 1, }) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } }, }) }) @@ -878,7 +878,7 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] const turnStarts = e.filter(x => x.type === 'turn/start').length const turnEnds = e.filter(x => x.type === 'turn/end').length expect(turnStarts).toBe(1) @@ -911,7 +911,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) @@ -940,12 +940,12 @@ describe('turn and step boundary recovery', () => { expect(errors).toEqual([]) // Session contains the observer failure per listener, so the committed turn // remains visible to later observers and executes normally. - const types = [...agent.session.events].map(e => e.type) + const types = [...agent.session.snapshotEvents()].map(e => e.type) expect(types.filter(t => t === 'turn/start')).toHaveLength(1) expect(types.filter(t => t === 'turn/end')).toHaveLength(1) - const lastBoundary = [...agent.session.events].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') + const lastBoundary = [...agent.session.snapshotEvents()].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') - expect(agent.session.events.at(-1)?.type).toBe('turn/end') + expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') // loop survives: a second turn runs normally. send(agent, 'second') @@ -977,7 +977,7 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') expect(stepEndIdx).toBeGreaterThanOrEqual(0) @@ -1011,7 +1011,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] // Both step/end and turn/end are present — finalization ran to completion. expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) @@ -1041,7 +1041,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. - const e1 = [...agent.session.events] + const e1 = [...agent.session.snapshotEvents()] expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1) expect(e1.at(-1)?.type).toBe('turn/end') @@ -1050,7 +1050,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - expect([...agent.session.events].filter(x => x.type === 'turn/end')).toHaveLength(2) + expect([...agent.session.snapshotEvents()].filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1082,7 +1082,7 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. - const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result') + const resultEvent = [...agent.session.snapshotEvents()].find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { expect(resultEvent.data.message.source.callId).toBe(ToolCallId('c1')) @@ -1146,7 +1146,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1194,7 +1194,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1244,7 +1244,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) // The post-listener cancellation check catches disposal before any step or LLM call. - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1291,7 +1291,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1336,7 +1336,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index d028451481..6c9ecc2020 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -69,13 +69,13 @@ describe('tool JSON parse', () => { await waitForIdle(ctx, agent) // tool/call event should have recorded the raw arguments string - const callEvent = agent.session.events.find(e => e.type === 'tool/call') + const callEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/call') expect(callEvent).toBeDefined() if (callEvent!.type === 'tool/call') { expect(callEvent!.data.arguments).toBe('not json') } // the loop did not crash — a result was produced - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) it('uses empty object when tool-call arguments are empty string', async () => { @@ -101,7 +101,7 @@ describe('tool JSON parse', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) }) @@ -130,9 +130,9 @@ describe('thrown-value propagation', () => { expect(errors).toHaveLength(1) expect(errors[0]).toBe('naked string error') expect(adapter.requests).toHaveLength(0) - const starts = agent.session.events.filter(event => event.type === 'turn/start') - const ends = agent.session.events.filter(event => event.type === 'turn/end') - const messages = agent.session.events.filter(event => event.type === 'user/message') + const starts = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const ends = agent.session.snapshotEvents().filter(event => event.type === 'turn/end') + const messages = agent.session.snapshotEvents().filter(event => event.type === 'user/message') expect(starts).toHaveLength(0) expect(ends).toHaveLength(0) expect(messages).toHaveLength(0) @@ -155,7 +155,7 @@ describe('thrown-value propagation', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message : undefined).toBe('[object Object]') @@ -180,7 +180,7 @@ describe('durable error rendering', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { expect(turnEnd.data.reason.error).toEqual({ @@ -236,7 +236,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, send(agent, 'go') await waitForIdle(ctx, agent) - const toolResult = agent.session.events.find(e => e.type === 'tool/result') + const toolResult = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(toolResult?.type === 'tool/result' && toolResult.data.message.content[0].isError).toBe(true) expect(toolResult?.type === 'tool/result' && toolResult.data.error) .toEqual({ name: 'HarnessError', code: 'BOOM' }) @@ -262,7 +262,7 @@ describe('request-error action edges', () => { // One failed request, no retry turn. expect(adapter.requests).toHaveLength(1) - const ends = agent.session.events.filter(e => e.type === 'turn/end') + const ends = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(ends).toHaveLength(1) }) @@ -284,7 +284,7 @@ describe('request-error action edges', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted') }) }) @@ -313,7 +313,7 @@ describe('stream failure edges', () => { // No facts -> not offered to recovery; the turn fails through settle(). expect(recoveries).toBe(0) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) @@ -388,7 +388,7 @@ describe('tool result meta persistence', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const result = agent.session.events.find(e => e.type === 'tool/result') + const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' }) }) }) @@ -436,7 +436,7 @@ describe('recovery without a retry action', () => { expect(recoveries).toBe(1) expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) @@ -461,7 +461,7 @@ describe('unrenderable failure settlement', () => { send(agent, 'go') await agent.whenIdle() - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { // The durable failure keeps the adapter facts' message, not the @@ -502,7 +502,7 @@ describe('driver bookkeeping edges', () => { expect(proposals).toBe(2) expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(event => event.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' }) }) @@ -524,9 +524,9 @@ describe('driver bookkeeping edges', () => { send(agent, 'go') await agent.whenIdle() - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/end')).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 756d1d7ccc..101e082f99 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -57,7 +57,7 @@ function send(agent: Agent, text: string) { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } describe('agent/pre-step', () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8f0df876c5..0158a2fa15 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -45,7 +45,7 @@ function send(agent: Agent, text: string) { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -186,7 +186,7 @@ describe('agent loop', () => { expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { @@ -209,13 +209,13 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) // Durable inbox receipt precedes the turn-owned transcript. expect(types[0]).toBe('agent/inbox/spliced') expect(types).toContain('turn/start') expect(types).toContain('user/message') expect(types).toContain('assistant/message') - const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length }) expect(types.at(-1)).toBe('turn/end') @@ -257,7 +257,7 @@ describe('agent loop', () => { expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }]) // session log records call + result - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') }) @@ -319,7 +319,7 @@ describe('agent loop', () => { expect(errors.map(error => error.message)).toEqual([ 'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")', ]) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message @@ -336,7 +336,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by DeepSeek Harness.\n\nIn /rescued.') - const turnEnds = agent.session.events.filter(e => e.type === 'turn/end') + const turnEnds = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(turnEnds).toHaveLength(2) expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') }) @@ -396,7 +396,7 @@ describe('agent loop', () => { let mode = 'read-only' const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` }) const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) - const contextEvents = () => agent.session.events.flatMap(event => + const contextEvents = () => agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -437,7 +437,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(contextEvents()).toHaveLength(3) expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system)) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -449,7 +449,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') @@ -464,7 +464,7 @@ describe('agent loop', () => { send(agent, 'after compaction') await waitForIdle(ctx, agent) - const runtimeContexts = agent.session.events.flatMap(event => + const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -484,7 +484,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') @@ -544,7 +544,7 @@ describe('agent loop', () => { send(agent, 'repair context') await waitForIdle(ctx, agent) - const runtimeContexts = agent.session.events.flatMap(event => + const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -565,7 +565,7 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) - const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk') + const chunkEvents = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk') // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7 expect(chunkEvents).toHaveLength(7) // replay: chunk events alone re-assemble to the recorded assistant message @@ -599,13 +599,13 @@ describe('agent loop', () => { send(agent, 'start') await waitForIdle(ctx, agent) - const steering = agent.session.events.find(e => + const steering = agent.session.snapshotEvents().find(e => e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans')) expect(steering).toBeDefined() // The entered batch is appended after the second step opens and before its // request derives history. const steeringSeq = steering!.seq - const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1] + const secondStepStart = agent.session.snapshotEvents().filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) @@ -623,12 +623,12 @@ describe('agent loop', () => { const idle = waitForIdle(ctx, agent) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })) expect(agent.status).toBe('running') - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })) await idle - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ [{ type: 'text', text: 'first idle steer' }], @@ -656,15 +656,15 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(1) expect(agent.inbox.nextStep).toHaveLength(1) send(agent, 'resume') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) @@ -676,8 +676,8 @@ describe('agent loop', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'agent/inbox/spliced', data: { target: 'next-step', @@ -691,7 +691,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) const flat = JSON.stringify(adapter.requests[0]!.messages) expect(flat).toContain('file changed: a.ts') expect(flat).not.toContain(' { send(agent, 'go') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(contextEvent?.type === 'user/message' && contextEvent.data.source) .toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) const requestText = JSON.stringify(adapter.requests[0]!.messages) @@ -732,7 +732,7 @@ describe('agent loop', () => { agent.inject(createUserMessage({ content: [first], source: { kind: 'plugin', plugin: 'x' } })) first.text = 'mutated after inject' agent.inject(createUserMessage({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } })) - visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, })) @@ -744,10 +744,10 @@ describe('agent loop', () => { // The injection stays in the open turn, but its user-role context cannot // split the assistant tool call from the provider's tool-result message. - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) - const result = agent.session.events.find(e => e.type === 'tool/result')! - const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result')! + const contexts = agent.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) @@ -791,7 +791,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('agent/turn-stopping can steer another step (/loop pattern)', async () => { @@ -836,7 +836,7 @@ describe('agent loop', () => { // only one model call despite the tool call requesting a follow-up expect(adapter.requests).toHaveLength(1) // The tool still executes and durably records its result. - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) it('continues for steering that arrived during a concluding tool step', async () => { @@ -862,7 +862,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - const events = agent.session.events.map(event => event.type) + const events = agent.session.snapshotEvents().map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('late steering') const texts = adapter.requests[1]!.messages @@ -891,7 +891,7 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') // The header event records what the request ACTUALLY used — the switch is // a reconstructable fact, not silent drift. - const headerEvent = agent.session.events.find(e => e.type === 'request/header') + const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header') expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) @@ -930,7 +930,7 @@ describe('agent loop', () => { let boundaryOpen = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { - if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' + if (subject === agent) boundaryOpen = subject.session.snapshotEvents().at(-1)?.type === 'step/start' return next() }) @@ -962,14 +962,14 @@ describe('agent loop', () => { // The first proposal failed inside a balanced turn without calling the model. expect(errors.map(error => error.message)).toEqual(['boom in pre-step']) expect(adapter.requests.length).toBe(0) - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) - expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/end')).toBe(true) // The loop survived: a second prompt runs a normal completed turn. send(agent, 'second') await waitForIdle(ctx, agent) expect(adapter.requests.length).toBe(1) - const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const lastTurnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) @@ -1007,7 +1007,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Assert the durable row, not only the live listener. - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) @@ -1111,7 +1111,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(executions).toBe(0) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, role: 'user', @@ -1121,7 +1121,7 @@ describe('agent loop', () => { expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Empty content still needs an assistant/message to carry usage; derivation // skips that host so it does not create a spurious assistant turn. - const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({ turn: 1, step: 1, @@ -1161,7 +1161,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ turn: 1, step: 1, @@ -1195,7 +1195,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'completed' }]) - const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ turn: 1, step: 1, @@ -1237,7 +1237,7 @@ describe('agent loop', () => { send(agent, 'continue') await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) // The follow-up request replays the truncated message with its replay // metadata pruned in step with the dropped tool call. expect(adapter.requests[1]?.messages[1]?.source).toEqual({ @@ -1305,7 +1305,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) @@ -1326,8 +1326,8 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - const turns = agent.session.events.filter(event => event.type === 'turn/start') - const messages = agent.session.events + const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) expect(turns).toHaveLength(1) @@ -1345,8 +1345,8 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle - const turns = agent.session.events.filter(event => event.type === 'turn/start') - const sources = agent.session.events + const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const sources = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.source) expect(turns).toHaveLength(2) @@ -1402,10 +1402,10 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - const messages = agent.session.events + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(messages).toEqual([ [{ type: 'text', text: 'outer message' }], [{ type: 'text', text: 'model callback message' }], @@ -1435,7 +1435,7 @@ describe('agent loop', () => { }) expect(reasons[0]).toMatchObject({ kind: 'error' }) // The durable failure and live relay describe the same failed turn. - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' }) }) @@ -1488,7 +1488,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]?.reasoningEffort).toBe(effort) - const header = agent.session.events.find(event => event.type === 'request/header') + const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) @@ -1526,11 +1526,11 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.snapshotEvents()] }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types over the inherited prefix - expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual( - agent.session.events.map(e => e.type)) - expect(replayed.events.at(-1)?.type).toBe('session/end-seed') + expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( + agent.session.snapshotEvents().map(e => e.type)) + expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 38d135abb3..1dc37ffc14 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -72,26 +72,26 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () } function userMessageTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } function turnNumbers(agent: Agent): number[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'turn/start') .map(e => e.data.turn) } function turnEndNumbers(agent: Agent): number[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'turn/end') .map(e => (e.data as { turn: number }).turn) } function userMessageCountsByTurn(agent: Agent): number[] { const counts: number[] = [] - for (const event of agent.session.events) { + for (const event of agent.session.snapshotEvents()) { if (event.type === 'turn/start') counts.push(0) if (event.type === 'user/message') counts[counts.length - 1]! += 1 } diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 71633906b3..dd1548cd7c 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const usages = [...agent.session.events] + const usages = [...agent.session.snapshotEvents()] .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2 diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 2ee77022a5..17af91a06e 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -92,13 +92,13 @@ describe('agent/request-error', () => { code: 'SERVICE_UNAVAILABLE', }, ]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(seen.map(item => item.retryPolicy)).toEqual([ expect.objectContaining({ mode: 'normal' }), expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -115,8 +115,8 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, }) @@ -137,8 +137,8 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 537ff03df9..3b426c0ca8 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -94,7 +94,7 @@ describe('request stability across the loop', () => { expect(Object.isFrozen(request.messages)).toBe(true) } // One anchoring header snapshot; no further header events (nothing changed). - const headerEvents = agent.session.events.filter(e => e.type === 'request/header') + const headerEvents = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(headerEvents).toHaveLength(1) expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial') }) @@ -111,7 +111,7 @@ describe('request stability across the loop', () => { expect(adapter.requests).toHaveLength(2) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -132,7 +132,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -157,7 +157,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => event.type === 'request/header' + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ { reason: 'initial', startsSeries: undefined }, @@ -194,7 +194,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -223,7 +223,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ ReasoningEffortId('high'), ReasoningEffortId('max'), @@ -242,7 +242,7 @@ describe('request stability across the loop', () => { const resumedCtx = await harness(resumedAdapter) const resumedHandle = await resumedCtx.agents.create({ sessionId: SessionId(`effort-${model}`), - seed: structuredClone(agent.session.events), + seed: structuredClone(agent.session.snapshotEvents()), agentOptions: { provider: 'mock', model }, }) send(resumedHandle.agent, 'resumed') @@ -250,7 +250,7 @@ describe('request stability across the loop', () => { expect(resumedAdapter.requests[0]?.model).toBe(model) expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort) - const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header') + const resumedHeaders = resumedHandle.agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(effort) expect(resumedHeaders.at(-1)?.data.reason).toBe('resume') } @@ -268,7 +268,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests[0]?.maxTokens).toBe(256_000) - const header = agent.session.events.find(event => event.type === 'request/header') + const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000) expect(header?.type === 'request/header' && header.data.header.adapterDefaults) .toEqual({ maxTokens: true }) @@ -299,7 +299,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(256_000) expect(other.requests[0]?.maxTokens).toBe(8_192) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([256_000, 8_192]) expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ { maxTokens: true }, @@ -333,7 +333,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(4_096) expect(other.requests[0]?.maxTokens).toBe(4_096) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([4_096, 4_096]) expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([undefined, undefined]) }) @@ -385,7 +385,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ]) expect(second.requests).toHaveLength(0) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) }) @@ -423,7 +423,7 @@ describe('request stability across the loop', () => { expect(signal.aborted).toBe(true) expect(handle.agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'request/header')).toBe(false) }) it.each(['plain error', 'LLM error'] as const)( @@ -446,7 +446,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: failure instanceof LlmError ? { kind: 'error', error: failure.failure } @@ -519,7 +519,7 @@ describe('request stability across the loop', () => { const second = adapter.requests[1]! // The rewritten history: summary replaces turn 1's user+assistant pair. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) - expect(agent.session.events.flatMap(event => event.type === 'request/header' + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ { reason: 'initial', startsSeries: undefined }, @@ -557,7 +557,7 @@ describe('request stability across the loop', () => { expect(adapter.requests[1]?.messages[0]?.content).toContainEqual({ type: 'text', text: '[summary for retry]', }) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -570,14 +570,14 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' }) send(agent, 'third') await waitForIdle(ctx, agent) - const snapshots = agent.session.events.filter(e => e.type === 'request/header') + const snapshots = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('change') expect(adapter.requests[2]!.system).toContain('new guidance') @@ -604,7 +604,7 @@ describe('request stability across the loop', () => { const first = adapter.requests[0]! // The inject landed in the log after the boundary: not in THIS request… expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) - expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') await waitForIdle(ctx, agent) @@ -631,7 +631,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } }) if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error() expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i) @@ -650,14 +650,14 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), - seed: [...agent.session.events], + seed: [...agent.session.snapshotEvents()], agentOptions: { provider: 'mock', model: 'mock' }, }) const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) - const snapshots = agent2.session.events.filter(e => e.type === 'request/header') + const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') // Identical header across the restart: byte-identical continuation. @@ -689,7 +689,7 @@ describe('request stability across the loop', () => { // The second turn reuses the same series and header; the session's own // fold remains immutable state. - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) expect(adapter.requests[1]!.temperature).toBeUndefined() @@ -715,7 +715,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(3) - const events = agent.session.events + const events = agent.session.snapshotEvents() const stepStarts = events.filter(e => e.type === 'step/start') expect(stepStarts).toHaveLength(3) @@ -771,7 +771,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - const records = agent.session.events.filter(event => event.type === 'request/context') + const records = agent.session.snapshotEvents().filter(event => event.type === 'request/context') expect(records).toHaveLength(1) expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 }) // Log-only: not a SurfaceEventType, so it can never reach a model request @@ -796,7 +796,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data.contextWindow)).toEqual([64_000, 256_000]) }) @@ -808,7 +808,7 @@ describe('request/context capacity records', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }]) }) @@ -828,7 +828,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([ { provider: 'mock', model: 'known', contextWindow: 64_000 }, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index df2849d39d..4688a97fa8 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -159,7 +159,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', })) await waitForIdle(ctx, handle.agent) expect(handle.agent.session.deriveMessages()).toHaveLength(5) - expect(handle.agent.session.events.at(-1)).toMatchObject({ + expect(handle.agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -317,7 +317,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setup: async (agentCtx) => { expect(agentCtx.agent?.id).toBe(sessionId) // The two persisted events plus the end-seed marker. - expect(agentCtx.agent?.session.events).toHaveLength(3) + expect(agentCtx.agent?.session.snapshotEvents()).toHaveLength(3) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -650,7 +650,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) - const events1 = [...a1.session.events] + const events1 = [...a1.session.snapshotEvents()] const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous await ctx1.fiber.dispose() @@ -672,18 +672,18 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') // …followed by one end-seed event marking the constructor seed. - expect(a2.session.events.length).toBe(events1.length + 1) + expect(a2.session.snapshotEvents().length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) - expect(a2.session.events.at(-1)?.type).toBe('session/end-seed') + expect(a2.session.snapshotEvents().at(-1)?.type).toBe('session/end-seed') const replay = Session.create(SessionId('replay'), events1) expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. a2.followup(createUserMessage({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })) await waitForIdle(ctx2, a2) - const allSeqs = a2.session.events.map(e => e.seq) + const allSeqs = a2.session.snapshotEvents().map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates - const turnStarts = a2.session.events.filter(e => e.type === 'turn/start') + const turnStarts = a2.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2]) await ctx2.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 6b78213200..6d9e7b33e9 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -781,7 +781,7 @@ describe('agent scope lifecycle', () => { expect(statuses).toEqual([]) expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) - expect(announced.session.events).toEqual([]) + expect(announced.session.snapshotEvents()).toEqual([]) expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -1086,9 +1086,9 @@ describe('agent scope lifecycle', () => { // are empty and nothing still drives the detached session. expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.id)).toBeUndefined() - const eventsAfter = agent.session.events.length + const eventsAfter = agent.session.snapshotEvents().length await new Promise(resolve => setTimeout(resolve, 30)) - expect(agent.session.events.length).toBe(eventsAfter) + expect(agent.session.snapshotEvents().length).toBe(eventsAfter) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index e4d2c993e9..8adccaa4b5 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } /** Build one assistant response containing the supplied tool calls. */ diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 563e5ba6c3..e7ffe77a5c 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -69,7 +69,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf describe('loop-level canonical tool order', () => { it('logs the request/header with tools in canonical order, not registration order', async () => { const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike']) - const header = foldRequestHeader(agent.session.events) + const header = foldRequestHeader(agent.session.snapshotEvents()) expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu']) // The dispatched request is built FROM the logged header (whose tools the // assembly already canonicalized) and reaches the adapter deep-frozen — @@ -82,14 +82,14 @@ describe('loop-level canonical tool order', () => { it('produces the same header order for any registration order', async () => { const first = await runTurn(['alpha', 'mike', 'zulu']) const second = await runTurn(['zulu', 'mike', 'alpha']) - const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name) + const names = (run: typeof first) => foldRequestHeader(run.agent.session.snapshotEvents())?.tools?.map(tool => tool.name) expect(names(first)).toEqual(['alpha', 'mike', 'zulu']) expect(names(second)).toEqual(names(first)) }) it('honors a configured toolOrder in the logged header and the dispatched request', async () => { const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST]) - const header = foldRequestHeader(agent.session.events) + const header = foldRequestHeader(agent.session.snapshotEvents()) expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike']) expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike']) expect(Object.isFrozen(adapter.requests[0])).toBe(true) @@ -103,10 +103,10 @@ describe('loop-level canonical tool order', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(foldRequestHeader(agent.session.events)).toBeUndefined() - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) - expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false) - expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false) + expect(foldRequestHeader(agent.session.snapshotEvents())).toBeUndefined() + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'step/start')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'step/end')).toBe(false) }) }) diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index c6b9204c92..fccfdcba64 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -29,7 +29,7 @@ export class Inbox { private readonly session: Session, private readonly notifications: InboxNotifications, ) { - for (const event of session.events.slice(session.header.seedLength ?? 0)) { + for (const event of session.snapshotEvents(session.header.seedLength ?? 0)) { if (event.type !== 'agent/inbox/spliced') continue try { this.apply(event.data) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 33ecfb3c7b..e1da38b652 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -124,13 +124,13 @@ describe('Inbox', () => { const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) inbox.append('next-turn', nextTurn) inbox.append('next-step', nextStep) - const beforeClear = session.events.length + const beforeClear = session.snapshotEvents().length inbox.clear() expect(inbox.hasPending).toBe(false) expect(discarded).toEqual([nextStep, nextTurn]) - expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' + expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' ? event.data : event.type)).toEqual([ { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, @@ -138,7 +138,7 @@ describe('Inbox', () => { ]) inbox.clear() - expect(session.events).toHaveLength(beforeClear + 2) + expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) }) }) diff --git a/packages/core/agent/tests/consumed-work.spec.ts b/packages/core/agent/tests/consumed-work.spec.ts index 3b79d67620..26808c5c81 100644 --- a/packages/core/agent/tests/consumed-work.spec.ts +++ b/packages/core/agent/tests/consumed-work.spec.ts @@ -40,7 +40,7 @@ describe('foldConsumedWork', () => { const session = Session.create(SessionId('empty')) accept(session, 'queued') - expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false }) + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ droppedUnrun: false }) }) it('reports the latest turn that entered a model step', () => { @@ -48,7 +48,7 @@ describe('foldConsumedWork', () => { steppedTurn(session, 1, { kind: 'completed' }) steppedTurn(session, 2, { kind: 'max-tokens' }) - expect(foldConsumedWork(session.events).end?.data) + expect(foldConsumedWork(session.snapshotEvents()).end?.data) .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) }) @@ -61,7 +61,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('reports a turn that claimed its input and was then stopped before any step', () => { @@ -71,7 +71,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('ignores a turn stopped, failed, or rejected without taking any input', () => { @@ -85,7 +85,7 @@ describe('foldConsumedWork', () => { session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } }) // None of these turns describes work: they opened, found nothing of their own, and closed. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('reports a turn whose claimed input a pre-step rejection discarded', () => { @@ -97,7 +97,7 @@ describe('foldConsumedWork', () => { // Rejection does not retain the claimed messages, so the `blocked` end is // the only account of input that will never run. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('ignores a claim its own turn emptied', () => { @@ -109,7 +109,7 @@ describe('foldConsumedWork', () => { // An emptied claim ran nothing and dropped nothing: a listener rewrote the // batch away, which is not this log's account of the work. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('credits a claim with no open turn to no turn at all', () => { @@ -120,7 +120,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('reports work cancelled out of the inbox after the last accounting turn', () => { @@ -130,8 +130,8 @@ describe('foldConsumedWork', () => { cancelPending(session) // No turn opened over it, so only the cancellation says the work was cut short. - expect(foldConsumedWork(session.events)).toEqual({ - end: session.events.find(event => event.type === 'turn/end'), + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + end: session.snapshotEvents().find(event => event.type === 'turn/end'), droppedUnrun: true, }) }) @@ -143,7 +143,7 @@ describe('foldConsumedWork', () => { target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled', }) - expect(foldConsumedWork(session.events).droppedUnrun).toBe(false) + expect(foldConsumedWork(session.snapshotEvents()).droppedUnrun).toBe(false) }) it('lets a later accounting turn absorb an earlier drop', () => { @@ -152,8 +152,8 @@ describe('foldConsumedWork', () => { cancelPending(session) steppedTurn(session, 2, { kind: 'completed' }) - expect(foldConsumedWork(session.events)).toEqual({ - end: session.events.findLast(event => event.type === 'turn/end'), + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + end: session.snapshotEvents().findLast(event => event.type === 'turn/end'), droppedUnrun: false, }) }) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index a1255e2fa4..20d1182786 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: b96423846de1f0c902ed656ff879f7d9eb0536ef -README.zh.md: 61f1ff77f9427e121b56ae0205b36e7512782030 +README.md: f490c53c846c00f8d0c91b7ad7fae3f688522a31 +README.zh.md: 0f32bc5ba04e6da2bc8907280ef2c8ea9dc81b9a diff --git a/packages/core/session/README.md b/packages/core/session/README.md index b96423846d..f490c53c84 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,6 +49,12 @@ session.deriveMessages() // the derived model history Surface events (`user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface; raw chunks, boundaries, and other log-only events never produce a message. +### Read the log + +`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`. + +`session.seeded` distinguishes construction with a replay, fork, or restored seed—including an explicitly empty seed—from a fresh session without scanning for `session/end-seed`. + ### Fork a session `ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 61f1ff77f9..0f32bc5ba0 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -49,6 +49,12 @@ session.deriveMessages() // the derived model history 表层事件(`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface;原始分片、边界与其他仅日志事件从不产生消息。 +### 读取日志 + +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 + +`session.seeded` 可区分以 replay、fork 或恢复 seed(包括显式空 seed)构造的会话与全新会话,无需扫描 `session/end-seed`。 + ### 派生会话的 fork `ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。 diff --git a/packages/core/session/src/chunk-rows.ts b/packages/core/session/src/chunk-rows.ts index b50a763a12..88d6d9e82b 100644 --- a/packages/core/session/src/chunk-rows.ts +++ b/packages/core/session/src/chunk-rows.ts @@ -7,7 +7,7 @@ * `tool-call-chunks` — and expands rows back to the exact original events. * * Packed rows are an encoding vocabulary, NOT session events: they never enter - * `Session.events`, have no `SessionEventMap` entry, and use bare (slash-less) + * `Session.snapshotEvents()`, have no `SessionEventMap` entry, and use bare (slash-less) * type tags so a reader cannot confuse them with the event taxonomy * (precedent: the JSONL header line's `session` tag). Persistence and bounded * history transport both use the codec. The encoder whitelists exact shapes — diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 84a89d325f..3d934b0884 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -469,6 +469,14 @@ export class Session { */ readonly firstLiveSeq: number + /** + * Whether construction received a replay, fork, or restored seed. + * Every seeded lifecycle has a `session/end-seed` marker after construction; + * this field distinguishes an explicitly empty seed from a fresh session + * without scanning the log. + */ + readonly seeded: boolean + /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -535,6 +543,7 @@ export class Session { } } this.firstLiveSeq = this.log.length + this.seeded = seed !== undefined this.header = restoredHeader ?? snapshotSessionHeader(id, header) // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped @@ -545,18 +554,32 @@ export class Session { } } - /** Cached immutable public snapshot of the private append-only log. */ + /** Cached immutable full snapshot of the private append-only log. */ private eventsSnapshot: readonly SessionEvent[] | undefined /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[] { - this.eventsSnapshot ??= Object.freeze([...this.log]) - return this.eventsSnapshot + eventAt(seq: number): SessionEvent | undefined { + return this.log[seq] + } + + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[] { + if (fromSeq === 0 && toSeqExclusive === this.log.length) { + this.eventsSnapshot ??= Object.freeze([...this.log]) + return this.eventsSnapshot + } + return Object.freeze(this.log.slice(fromSeq, toSeqExclusive)) } /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ @@ -661,7 +684,7 @@ export class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -1092,9 +1115,8 @@ export class SessionStore extends Service { }) } - private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] { - const events = session.events - const lastEvent = events.at(-1) + private _forkSeed(session: Session, requestedBoundary: number | undefined): readonly SessionEvent[] { + const lastEvent = session.eventAt(session.seq - 1) let boundary: number if (requestedBoundary !== undefined) { boundary = requestedBoundary @@ -1108,22 +1130,23 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } - if (boundary >= events.length) { - const lastSeq = events.at(-1)?.seq + if (boundary >= session.seq) { + const lastSeq = lastEvent?.seq throw new SessionForkError( `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY', ) } - const boundaryEvent = events[boundary] + const boundaryEvent = session.eventAt(boundary) if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) { throw new SessionForkError( `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, 'INVALID_BOUNDARY', ) } - const lastTurnBoundary = events.slice(0, boundary + 1) + const events = session.snapshotEvents(0, boundary + 1) + const lastTurnBoundary = events .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') if (lastTurnBoundary?.type === 'turn/start') { throw new SessionForkError( @@ -1132,7 +1155,7 @@ export class SessionStore extends Service { ) } - return events.slice(0, boundary + 1) + return events } private _resolveForkSource(source: SessionForkSource): Session { diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 5512cd0b97..88a1646a06 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -206,7 +206,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seedSession = (session: Session): SessionTrace => { const trace = freshTrace() traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { applyTransition(trace, validateEvent(trace, event, fail)) } return trace diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index f93c898375..364aa44a61 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,7 +16,7 @@ function userText(session: Session, text: string): void { /** From-scratch oracle: replay the log into a fresh session and derive. */ function scratch(session: Session): unknown { - return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages() + return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.snapshotEvents()]).deriveMessages() } describe('derived-message cache', () => { diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 6a7770b4b6..537bf9c3ee 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -47,14 +47,14 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m } function lastSeq(session: Session): number { - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) if (event === undefined) throw new Error('missing last event') return event.seq } /** A seeded child's constructor seed: its log minus the end-seed marker. */ function inherited(session: Session): readonly SessionEvent[] { - const events = session.events + const events = session.snapshotEvents() const last = events.at(-1) if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker') return events.slice(0, -1) @@ -83,19 +83,19 @@ describe('SessionStore.fork', () => { const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) - expect(inherited(child)).toEqual(source.events) - expect(child.events).not.toBe(source.events) - expect(child.events[1]).not.toBe(source.events[1]) + expect(inherited(child)).toEqual(source.snapshotEvents()) + expect(child.snapshotEvents()).not.toBe(source.snapshotEvents()) + expect(child.snapshotEvents()[1]).not.toBe(source.snapshotEvents()[1]) expect(() => { - firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } + firstUserMessage(child.snapshotEvents()).data.content[0] = { type: 'text', text: 'child mutation' } }).toThrow(TypeError) - expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), cwd: '/workspace', parentSession: SessionId('parent'), - seedLength: source.events.length, + seedLength: source.snapshotEvents().length, }) }) @@ -107,7 +107,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('log-only-child')) - expect(inherited(child)).toEqual(source.events) + expect(inherited(child)).toEqual(source.snapshotEvents()) expect(inherited(child).at(-1)).toMatchObject({ type: 'test/log-only', data: { value: 'after execution' }, @@ -124,7 +124,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) - expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1)) + expect(inherited(child)).toEqual(source.snapshotEvents().slice(0, firstBoundary + 1)) expect(child.header.seedLength).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, @@ -152,7 +152,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`)) expect(inherited(child).at(-1)?.type).toBe('turn/end') - expect(child.header.seedLength).toBe(source.events.length) + expect(child.header.seedLength).toBe(source.snapshotEvents().length) } }) @@ -167,10 +167,10 @@ describe('SessionStore.fork', () => { const child = sessions.fork(parent, undefined, SessionId('bracket-child')) // Parent: no end-seed event follows the bracket, so its owner treats it as live. - expect(parent.events.at(-1)).toBe(open) - expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false) + expect(parent.snapshotEvents().at(-1)).toBe(open) + expect(parent.snapshotEvents().some(event => event.type === 'session/end-seed')).toBe(false) // Child: the same bracket is before end-seed, so it belongs to the seed. - const boundary = child.events.at(-1) + const boundary = child.snapshotEvents().at(-1) expect(boundary).toMatchObject({ type: 'session/end-seed' }) expect(boundary!.seq).toBeGreaterThan(open.seq) expect(child.firstLiveSeq).toBe(open.seq + 1) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 0b9a282d45..26aede8930 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -79,7 +79,7 @@ describe('session-log invariants', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('later dispatch veto') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(() => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -413,7 +413,7 @@ describe('session-log invariants', () => { const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] }) - expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) + expect(open.snapshotEvents().map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) // Still open afterwards: the boundary moves no cursor. expect(() => open.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index e27c0c8d44..4829d58a89 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -102,7 +102,7 @@ describe('Session properties', () => { it('seq is strictly monotonic and zero-based contiguous', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) - session.events.forEach((event, i) => { expect(event.seq).toBe(i) }) + session.snapshotEvents().forEach((event, i) => { expect(event.seq).toBe(i) }) expect(session.seq).toBe(events.length) })) }) @@ -110,10 +110,10 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events]) + const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.snapshotEvents()]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // Every explicit replay grows by exactly one log-only boundary. - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) })) }) @@ -121,10 +121,10 @@ describe('Session properties', () => { it('replaying a log that already ends in end-seed adds no further marker', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events]) - const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events]) + const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.snapshotEvents()]) + const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.snapshotEvents()]) // Lazy resume makes browsing a pickup, so this must not grow per open. - expect(twice.events).toEqual(once.events) + expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) }) @@ -157,7 +157,7 @@ describe('Session properties', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) const messages = session.deriveMessages() - const before = structuredClone(session.events) + const before = structuredClone(session.snapshotEvents()) for (const m of messages) { expect(['user', 'assistant', 'system']).toContain(m.role) // Derived messages are frozen shared projections: mutation THROWS @@ -165,7 +165,7 @@ describe('Session properties', () => { expect(Object.isFrozen(m)).toBe(true) expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError) } - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) })) }) }) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 764b615877..17d7ba80de 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -82,7 +82,7 @@ describe('foldRequestHeader', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' }) - expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } }) + expect(foldRequestHeader(session.snapshotEvents())).toEqual({ config: { provider: 'mock', model: 'other' } }) }) }) @@ -97,7 +97,7 @@ describe('legacy request-header format', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header-delta', { config: CONFIG })) .toThrow(/unsupported legacy request\/header-delta/) - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('rejects the removed fallback reason in seeds and untyped appends', () => { @@ -111,7 +111,7 @@ describe('legacy request-header format', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' })) .toThrow('unsupported legacy request/header reason "fallback"') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d74112c833..05a5398877 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -65,7 +65,7 @@ describe('Session', () => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) - const turnEnd = session.events.findLast(e => e.type === 'turn/end')! + const turnEnd = session.snapshotEvents().findLast(e => e.type === 'turn/end')! expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' }) // survives a structuredClone (the persistence-serialization boundary) expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) @@ -75,9 +75,9 @@ describe('Session', () => { const session = Session.create(SessionId('aborted')) session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events)) - expect(replayed.events.slice(0, -1)).toEqual(session.events) - const turnEnd = replayed.events.findLast(event => event.type === 'turn/end') + const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.snapshotEvents())) + expect(replayed.snapshotEvents().slice(0, -1)).toEqual(session.snapshotEvents()) + const turnEnd = replayed.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -109,7 +109,7 @@ describe('Session', () => { session.append('user/message', message, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual([message]) - const event = session.events[0] + const event = session.snapshotEvents()[0] expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) }) @@ -132,27 +132,29 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('s3-replay'), [...original.events]) + const replayed = Session.create(SessionId('s3-replay'), [...original.snapshotEvents()]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // The seed verbatim, plus the end-seed event the constructor appends. - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) expect(replayed.firstLiveSeq).toBe(original.seq) }) it('marks an explicitly empty seed without marking a fresh session', () => { const fresh = Session.create(SessionId('fresh-empty')) - expect(fresh.events).toEqual([]) + expect(fresh.seeded).toBe(false) + expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) + expect(resumed.seeded).toBe(true) expect(resumed.firstLiveSeq).toBe(0) - expect(resumed.events).toMatchObject([ + expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, ]) - const reopened = Session.create(SessionId('reopened-empty'), resumed.events) + const reopened = Session.create(SessionId('reopened-empty'), resumed.snapshotEvents()) expect(reopened.firstLiveSeq).toBe(1) - expect(reopened.events).toEqual(resumed.events) + expect(reopened.snapshotEvents()).toEqual(resumed.snapshotEvents()) }) it('rejects pre-provider request headers and assistant messages on seed/load', () => { @@ -181,7 +183,7 @@ describe('Session', () => { const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent - expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1)) + expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).snapshotEvents().slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -387,7 +389,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0]) + expect(Session.create(SessionId('reasoning-effort'), [valid]).snapshotEvents()[0]) .toEqual(valid) for (const reasoningEffort of ['', 1]) { @@ -417,7 +419,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) + expect(Session.create(SessionId('adapter-defaults'), [valid]).snapshotEvents()[0]).toEqual(valid) for (const adapterDefaults of [ null, @@ -447,7 +449,7 @@ describe('Session', () => { isError: false, }), }, { surfaceOp: 'append' }) - const before = structuredClone(session.events) + const before = structuredClone(session.snapshotEvents()) // A misbehaving consumer tries to mutate the messages it was handed. const messages = session.deriveMessages() @@ -463,7 +465,7 @@ describe('Session', () => { messages.reverse() // The log is unchanged: deep-equal to the snapshot taken before mutation. - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) // And a fresh derivation still reflects the original content and order. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }]) }) @@ -492,7 +494,7 @@ describe('Session', () => { cyclic['self'] = cyclic expect(bad(cyclic)).toThrow(/non-JSON-serializable/) // The rejected appends never entered the log. - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { @@ -506,13 +508,13 @@ describe('Session', () => { }))) .toThrow(/surface-eligible and requires a surfaceOp marker/) // The rejected append never entered the log (only turn/start is present). - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) it('accepts dense arrays and nested plain objects', () => { const session = Session.create(SessionId('s6')) expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow() - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) it('validates seed events: rejects a non-JSON-serializable seed', () => { @@ -556,7 +558,7 @@ describe('Session', () => { { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] const session = Session.create(SessionId('seed-ok'), goodSeed) - expect(session.events.slice(0, 3)).toEqual(goodSeed) + expect(session.snapshotEvents().slice(0, 3)).toEqual(goodSeed) expect(session.firstLiveSeq).toBe(3) }) @@ -581,7 +583,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) - expect(session.events.slice(0, 1)).toEqual([accepted]) + expect(session.snapshotEvents().slice(0, 1)).toEqual([accepted]) }) it('reads a nested seed-data getter once and stores its first JSON value', () => { @@ -598,7 +600,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-nested-drift'), seed) expect(reads).toBe(1) - expect(session.events[0]!.data).toEqual({ value: 'accepted' }) + expect(session.snapshotEvents()[0]!.data).toEqual({ value: 'accepted' }) }) it('rejects non-JSON surface metadata in a seed event', () => { @@ -659,7 +661,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-null-prototype'), [event]) - expect(session.events.slice(0, 1)).toEqual([{ ...event }]) + expect(session.snapshotEvents().slice(0, 1)).toEqual([{ ...event }]) }) it('reads a nested seed-metadata getter once and stores its first JSON value', () => { @@ -691,7 +693,7 @@ describe('Session', () => { }] as unknown as SessionEvent[] const session = Session.create(SessionId('seed-unstable-metadata'), seed) - const event = session.events[1]! + const event = session.snapshotEvents()[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') expect(reads).toBe(1) @@ -734,7 +736,7 @@ describe('Session', () => { } }) - it('snapshots the seed: mutating the original after construction does not affect session.events', () => { + it('snapshots the seed: mutating the original after construction does not affect session.snapshotEvents()', () => { const seed = [ { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: { @@ -747,16 +749,16 @@ describe('Session', () => { const session = Session.create(SessionId('seed-snapshot'), seed) // Mutate the ORIGINAL seed objects after construction: a shared reference // would let this rewrite the forked log (or reintroduce non-serializable - // data past validation). The snapshot must shield session.events. + // data past validation). The snapshot must shield session.snapshotEvents(). const um = seed[1]! ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' ;(um.data as Record)['injected'] = 1n // would have failed validation - const logged = session.events[1]! + const logged = session.snapshotEvents()[1]! expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') expect((logged.data as Record)['injected']).toBeUndefined() }) - it('snapshots append data: mutating the passed object after append does not affect session.events', () => { + it('snapshots append data: mutating the passed object after append does not affect session.snapshotEvents()', () => { const session = Session.create(SessionId('append-snapshot')) const data = { id: MessageId('append-input'), @@ -766,10 +768,10 @@ describe('Session', () => { } const event = session.append('user/message', data, { surfaceOp: 'append' }) // Mutate the caller's object after append returns. A shared reference would - // make session.events diverge from the value that passed validation. + // make session.snapshotEvents() diverge from the value that passed validation. data.content[0]!.text = 'HACKED' ;(data as Record)['injected'] = 1n - const logged = session.events[0]! + const logged = session.snapshotEvents()[0]! expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') expect((logged.data as Record)['injected']).toBeUndefined() // The returned event carries the same snapshot, not the caller's input. @@ -791,7 +793,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.data).toEqual({ value: 'accepted' }) - expect(session.events).toEqual([event]) + expect(session.snapshotEvents()).toEqual([event]) }) it('rejects non-JSON surface metadata before appending the event', () => { @@ -804,7 +806,7 @@ describe('Session', () => { }), { surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never, )).toThrow(/non-JSON-serializable surface metadata/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('rejects exotic surface metadata before cloning can erase its prototype', () => { @@ -822,7 +824,7 @@ describe('Session', () => { }), { surfaceOp: new ReplaceOp() }, )).toThrow(/non-JSON-serializable surface metadata/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('reads a nested append-metadata getter once and stores its first JSON value', () => { @@ -853,7 +855,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect(session.events).toEqual([source, event]) + expect(session.snapshotEvents()).toEqual([source, event]) }) it('rejects invalid plain surface metadata shapes at append', () => { @@ -874,7 +876,7 @@ describe('Session', () => { surfaceOp: 'append', sourceEventSeqs: [0, -1], })).toThrow(/non-negative safe integers/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('rejects surface metadata on non-surface append and seed events', () => { @@ -897,7 +899,7 @@ describe('Session', () => { data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('deep-freezes seeded and appended event snapshots', () => { @@ -907,7 +909,7 @@ describe('Session', () => { time: 1, data: { turn: 1 }, }]) - const seededEvent = seeded.events[0]! + const seededEvent = seeded.snapshotEvents()[0]! if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') expect(Object.isFrozen(seededEvent)).toBe(true) expect(Object.isFrozen(seededEvent.data)).toBe(true) @@ -957,21 +959,38 @@ describe('Session', () => { it('returns cached frozen event-array snapshots that do not grow after append', () => { const session = Session.create(SessionId('events-snapshot')) session.append('turn/start', { turn: 1 }) - const before = session.events + const before = session.snapshotEvents() const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') - expect(session.events).toBe(before) + expect(session.snapshotEvents()).toBe(before) expect(Object.isFrozen(before)).toBe(true) expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError) expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const after = session.events + const after = session.snapshotEvents() expect(before).toHaveLength(1) expect(after).toHaveLength(2) expect(after).not.toBe(before) - expect(session.events).toBe(after) + expect(session.snapshotEvents()).toBe(after) + }) + + it('reads one event without materializing an array and snapshots half-open ranges', () => { + const session = Session.create(SessionId('event-reads')) + const start = session.append('turn/start', { turn: 1 }) + const end = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + expect(session.eventAt(0)).toBe(start) + expect(session.eventAt(1)).toBe(end) + expect(session.eventAt(2)).toBeUndefined() + + const range = session.snapshotEvents(1, 2) + expect(range).toEqual([end]) + expect(Object.isFrozen(range)).toBe(true) + + session.append('turn/start', { turn: 2 }) + expect(range).toEqual([end]) }) it('detaches and freezes an explicitly supplied session header', () => { @@ -1122,7 +1141,7 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.snapshotEvents()] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -1386,7 +1405,7 @@ describe('SessionStore', () => { const heard: SessionEvent[] = [] let committedBeforeNotify = false ctx.on('session/event', (observedSession, event) => { - committedBeforeNotify = observedSession.events.at(-1) === event + committedBeforeNotify = observedSession.snapshotEvents().at(-1) === event throw new Error('sync event observer') }) ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never) @@ -1399,7 +1418,7 @@ describe('SessionStore', () => { }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) await Promise.resolve() await Promise.resolve() @@ -1422,7 +1441,7 @@ describe('SessionStore', () => { const [observedSession, event] = args as [Session, SessionEvent] validations.push({ event, - logLength: observedSession.events.length, + logLength: observedSession.snapshotEvents().length, frozen: Object.isFrozen(event) && Object.isFrozen(event.data), }) if (reject) { @@ -1435,7 +1454,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('reject first candidate') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) const appended = session.append('turn/start', { @@ -1447,7 +1466,7 @@ describe('SessionStore', () => { ]) expect(validations.map(({ event }) => event.seq)).toEqual([0, 0]) expect(validations[1]!.event).toBe(appended) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(observed).toEqual([appended]) }) @@ -1486,7 +1505,7 @@ describe('SessionStore', () => { sourceEventSeqs: [2], })).toThrow('reject surface candidate') - expect(session.events).toHaveLength(3) + expect(session.snapshotEvents()).toHaveLength(3) expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) @@ -1511,7 +1530,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('dispatch instrumentation rejected the carrier') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) }) @@ -1530,7 +1549,7 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, }) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) expect(warnings).toEqual([ 'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published', @@ -1561,7 +1580,7 @@ describe('SessionStore', () => { turn: 1, }) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached']) expect(ctx.sessions.get(session.id)).toBeUndefined() }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 4be69465c3..60ce0ebd54 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -318,7 +318,7 @@ describe('SurfaceManager', () => { }), }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] }) - const folded = foldSurface(s.events) + const folded = foldSurface(s.snapshotEvents()) expect(folded.nodes).toEqual(s.surface.nodes) expect(folded.replacements).toEqual([ { seq: 2, start: 0, end: 0, shadowedSeqs: [0] }, @@ -327,8 +327,8 @@ describe('SurfaceManager', () => { folded.nodes[0] = 99 folded.replacements[0]!.shadowedSeqs.push(99) expect(s.surface.nodes).toEqual([3]) - expect(foldSurface(s.events).nodes).toEqual([3]) - expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) + expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3]) + expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0]) }) it('does not retain fold-only replacement history in incremental state', () => { @@ -351,7 +351,7 @@ describe('SurfaceManager', () => { expect(s.surface.nodes).toEqual([1]) const manager = s.surface as unknown as { _state: object } expect(Object.hasOwn(manager._state, 'replacements')).toBe(false) - expect(foldSurface(s.events).replacements).toEqual([ + expect(foldSurface(s.snapshotEvents()).replacements).toEqual([ { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, ]) }) @@ -375,7 +375,7 @@ describe('SurfaceManager', () => { const surface = s.surface const nodes = surface.nodes - expect(nodes).toEqual(foldSurface(s.events).nodes) + expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) expect(surface.replaceGeneration).toBe(0) expect(() => s.append( @@ -394,11 +394,11 @@ describe('SurfaceManager', () => { { surfaceOp: { op: 'replace', start: 0, end: 0 } }, )).toThrow(/missing 0/) - expect(s.events).toHaveLength(1) + expect(s.snapshotEvents()).toHaveLength(1) expect(s.surface).toBe(surface) expect(surface.nodes).toEqual([0]) expect(surface.replaceGeneration).toBe(0) - expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'b' }], source: { kind: 'user' }, @@ -406,7 +406,7 @@ describe('SurfaceManager', () => { expect(surface.nodes).toBe(nodes) expect(surface.nodes).toEqual([0, 1]) expect(surface.replaceGeneration).toBe(0) - expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { @@ -479,7 +479,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - const replayed = Session.create(SessionId('replay'), [...original.events]) + const replayed = Session.create(SessionId('replay'), [...original.snapshotEvents()]) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) @@ -645,7 +645,7 @@ describe('SurfaceManager', () => { // Mutate caller's array after append. sources.push(1) sources[0] = 99 - const logged = s.events[1]! as SurfaceEvent + const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([0]) }) @@ -697,7 +697,7 @@ describe('SurfaceManager', () => { }, { surfaceOp: op, sourceEventSeqs: [0] }) // Mutate caller's object after append. op.start = 99 - const logged = s.events[1]! as SurfaceEvent + const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) }) @@ -797,8 +797,8 @@ describe('Session.append surface opts', () => { expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) - expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append') + expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append') }) it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => { @@ -830,8 +830,8 @@ describe('Session.append surface opts', () => { it('a non-surface event carries no surface fields', () => { const s = Session.create(SessionId('noopts')) s.append('turn/start', { turn: 1 }) - expect((s.events[0] as SessionEvent).sourceEventSeqs).toBeUndefined() - expect((s.events[0] as SessionEvent).surfaceOp).toBeUndefined() + expect((s.snapshotEvents()[0] as SessionEvent).sourceEventSeqs).toBeUndefined() + expect((s.snapshotEvents()[0] as SessionEvent).surfaceOp).toBeUndefined() }) it('surfaceOp primitives are not cloned (they are immutable)', () => { @@ -882,13 +882,13 @@ describe('surface type guards', () => { it('isSurfaceEvent narrows a fully-formed surface event', () => { const s = surfaceSession() - const userMessage = s.events.find(e => e.type === 'user/message')! + const userMessage = s.snapshotEvents().find(e => e.type === 'user/message')! expect(isSurfaceEvent(userMessage)).toBe(true) }) it('isSurfaceEvent rejects a non-surface-eligible type', () => { const s = surfaceSession() - const turnStart = s.events.find(e => e.type === 'turn/start')! + const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! expect(isSurfaceEvent(turnStart)).toBe(false) }) @@ -913,8 +913,8 @@ describe('surface type guards', () => { s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' }, }), { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) - const appended = s.events.find(e => e.type === 'user/message')! - const replacement = s.events.at(-1)! + const appended = s.snapshotEvents().find(e => e.type === 'user/message')! + const replacement = s.snapshotEvents().at(-1)! expect(isAppendSurfaceEvent(appended)).toBe(true) expect(isReplacementSurfaceEvent(appended)).toBe(false) @@ -924,7 +924,7 @@ describe('surface type guards', () => { it('rejects log-only and markerless events from both marker guards', () => { const s = surfaceSession() - const turnStart = s.events.find(e => e.type === 'turn/start')! + const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! // A surface-eligible type whose mandatory marker is absent has no origin at // all: it never entered the surface. const markerless: SessionEvent = { diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 5489f61d80..539fc43373 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -58,7 +58,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): number | null => { let openTurn: number | null = null dispatchRoots.set(session, new Map()) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateDispatch(session, event) commitDispatch(session, event) if (event.type === 'turn/start') openTurn = event.data.turn diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index db3c0b905c..c68ae14073 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -152,7 +152,7 @@ describe('tool-pipeline invariants', () => { name: 'echo', arguments: {}, })).toThrow(/parentCallId child does not belong to rootCallId another-root/) - expect(session.events.some(event => event.type === 'tool/code-dispatch-start' + expect(session.snapshotEvents().some(event => event.type === 'tool/code-dispatch-start' && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false) }) diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 6a29701aa4..dfeb911843 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -1483,7 +1483,7 @@ describe('the run_code dispatch bridge', () => { expect(result.isError).toBe(false) expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth }) expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' }) - const dispatch = session.events.find(event => event.type === 'tool/code-dispatch') + const dispatch = session.snapshotEvents().find(event => event.type === 'tool/code-dispatch') if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event') const logged = dispatch.data.arguments as { nested: JsonValue } let loggedDepth = 0 diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 94d16b50fe..3b7a8642b7 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -254,11 +254,11 @@ describe('dsh-agent-spine-demo bundle', () => { await waitForIdle(ctx, handle.agent) expect(adapter.requests).toBe(2) - const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') + const retryEvents = handle.agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(retryEvents).toHaveLength(1) expect(retryEvents[0]?.data.retry).toBe(1) expect(retryEvents[0]?.data).toMatchObject({ provider: 'mock', mode: 'normal', maxRetries: 1 }) - expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover') + expect(handle.agent.session.snapshotEvents().find(event => event.type === 'session/title')?.data.title).toBe('recover') expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') await handle.dispose() await ctx.fiber.dispose() @@ -512,7 +512,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(loadedRequest).toContain('') expect(loadedRequest).toContain('Use the freshly loaded body.') - const transcript = handle.agent.session.events.flatMap>((event) => { + const transcript = handle.agent.session.snapshotEvents().flatMap>((event) => { if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') { return [{ type: event.type, diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 436e494fb1..aa1e972673 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -306,7 +306,7 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { - const suffix = session.events.slice(session.header.seedLength ?? 0) + const suffix = session.snapshotEvents(session.header.seedLength ?? 0) return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) } diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 907df82daa..f975c11d68 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -373,7 +373,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) - const suffix = session.events.slice(session.header.seedLength ?? 0) + const suffix = session.snapshotEvents(session.header.seedLength ?? 0) if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue await progress.promise @@ -480,6 +480,6 @@ export class TeamRoster { /** Whether a Session's own suffix identifies a provider-owned subagent child. */ private subagentDescriptor(agent: Agent): boolean { - return foldSubagentDescriptor(agent.session.events.slice(agent.session.header.seedLength ?? 0)) !== undefined + return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.header.seedLength ?? 0)) !== undefined } } diff --git a/packages/experimental/agent-team/tests/invariant.spec.ts b/packages/experimental/agent-team/tests/invariant.spec.ts index ba12a07c74..04d99d75a5 100644 --- a/packages/experimental/agent-team/tests/invariant.spec.ts +++ b/packages/experimental/agent-team/tests/invariant.spec.ts @@ -44,7 +44,7 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - expect(invalid.events).toEqual([]) + expect(invalid.snapshotEvents()).toEqual([]) }) it('rejects an invalid task dependency before publication', async () => { @@ -69,6 +69,6 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) }) diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 88e72e5506..b3e63c557c 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -32,7 +32,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) + for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected return { diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 21d0e61b4a..5596dde448 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -35,7 +35,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) + for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected return { @@ -456,7 +456,7 @@ describe('Team identity and provisioning', () => { await ctx.agentTeams.createTask(lead, { subject: 'parent task', description: 'belongs to parent' }) const handle = await ctx.agents.create({ sessionId: SessionId('ordinary-fork'), - seed: lead.session.events, + seed: lead.session.snapshotEvents(), meta: { parentSession: lead.id, seedLength: lead.session.seq }, agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -979,13 +979,13 @@ describe('Team mailbox and waiting', () => { 'team/message/delivered', ]) - const receiptCount = lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + const receiptCount = lead.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId))).length await teamFiber.dispose() await ctx.plugin(TeamService, { maxPendingMessagesPerMember: 1 }) await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) - expect(lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(lead.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId)))).toHaveLength(receiptCount) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 28e8d3dc06..0730e5f8b4 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4740,7 +4740,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n readonly seeded: boolean;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): 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', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 6fa539beb6..647eeb3da4 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -94,7 +94,7 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s /** Authoritative feedback payloads in log order. */ function feedbackTexts(session: Session): string[] { - return session.events + return session.snapshotEvents() .filter(event => event.type === 'feedback/record') .map(event => event.data.text) } @@ -128,15 +128,15 @@ describe('/feedback human command', () => { text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is not configured.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) - const commandRun = test.session.events.find(event => event.type === 'command/run') + const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run') expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) - expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + expect(JSON.stringify(test.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) }) it('exports a command-independent feedback producer', async () => { const test = await harness() commandFeedback.recordFeedback(test.session, ' recorded outside a command ') - expect(test.session.events.map(event => event.type)).toEqual(['feedback/record']) + expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record']) expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') }) .toThrow('feedback text must not be empty') @@ -146,7 +146,7 @@ describe('/feedback human command', () => { it('keeps command bookkeeping around the authoritative feedback event', async () => { const test = await harness() await run(test, ' nothing else happens') - expect(test.session.events.map(event => event.type)).toEqual([ + expect(test.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'feedback/record', 'command/done', ]) }) @@ -209,11 +209,11 @@ describe('/feedback human command', () => { it('keeps every recorded event out of model context and derived history', async () => { const test = await harness() await run(test, ' invisible to the model') - for (const event of test.session.events) { + for (const event of test.session.snapshotEvents()) { expect('surfaceOp' in event).toBe(false) expect(test.session.deriveEventMessage(event)).toBeNull() } - expect(foldSurface(test.session.events).nodes).toEqual([]) + expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([]) expect(test.session.surface.nodes).toEqual([]) expect(test.session.deriveMessages()).toEqual([]) }) @@ -228,9 +228,9 @@ describe('/feedback human command', () => { await expect(run(test, ' \n\t ')).resolves.toEqual(expected) expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) - const done = test.session.events.filter(event => event.type === 'command/done') + const done = test.session.snapshotEvents().filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) - for (const event of test.session.events) { + for (const event of test.session.snapshotEvents()) { if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) } }) @@ -241,6 +241,6 @@ describe('/feedback human command', () => { controller.abort(new Error('user cancelled the command')) await expect(test.ctx.commands.execute(test.agent, '/feedback too late', [], controller.signal)) .rejects.toThrow('user cancelled the command') - expect(test.session.events).toEqual([]) + expect(test.session.snapshotEvents()).toEqual([]) }) }) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index d133d86830..624ca05af5 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -102,13 +102,13 @@ describe('/feedback real Loader composition through cordis.yml', () => { }) // The domain event owns the payload; generic command bookkeeping omits it. - expect(owner.session.events.map(event => event.type)) + expect(owner.session.snapshotEvents().map(event => event.type)) .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) - const run = owner.session.events.find(event => event.type === 'command/run') + const run = owner.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) - const feedback = owner.session.events.find(event => event.type === 'feedback/record') + const feedback = owner.session.snapshotEvents().find(event => event.type === 'feedback/record') expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable') - expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + expect(JSON.stringify(owner.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. expect(owner.session.deriveMessages()).toEqual([]) diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index f653ac6d63..748373856d 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -135,7 +135,7 @@ class TestPersistence extends SessionPersistence { const explicit = this.logical.get(id) if (explicit !== undefined) return Promise.resolve(explicit) const live = this.ctx.sessions.get(id) - if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.snapshotEvents() }) const stored = this.durable.get(id) return stored === undefined ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) @@ -171,7 +171,7 @@ class TestPersistence extends SessionPersistence { } persist(session: Session): void { - this.durable.set(session.id, { meta: session.header, events: session.events }) + this.durable.set(session.id, { meta: session.header, events: session.snapshotEvents() }) } setDurable(inspection: SessionInspection): void { diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 5c45e6a0bc..82c49a93b7 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,7 +62,7 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') - persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) const corruption = new Error('stored log checksum mismatch') persistence.inspectFailure = corruption await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) @@ -258,7 +258,7 @@ describe('MessageFeedbackService public contract', () => { rawCtx.provide('sessions', { get: () => undefined } as never) rawCtx.provide('sessionPersistence', { listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), - inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }), } as never) const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) await expect(raw.list({ sessionId: fixture.session.id })) @@ -437,7 +437,7 @@ describe('MessageFeedbackService item concurrency', () => { const replacement = Session.create( old.session.id, - old.session.events, + old.session.snapshotEvents(), { ...old.session.header, createdAt: 20, cwd: '/new' }, ) persistence.persist(replacement) @@ -518,7 +518,7 @@ describe('MessageFeedbackService durability ordering', () => { const fixture = messageFixture('cold-prefix') persistence.logical.set(fixture.session.id, { meta: fixture.session.header, - events: fixture.session.events, + events: fixture.session.snapshotEvents(), }) persistence.setDurable({ meta: fixture.session.header, events: [] }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 387fc2410c..ac422b481f 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,7 +43,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => expect(content).not.toContain('draft') // The log records real read/write/edit tool calls (not bash). - const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name) + const calls = [...agent.session.snapshotEvents()].filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') expect(calls).toContain('edit') diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5e50e7fc4e..ecd258f24c 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -810,7 +810,7 @@ describe('sandbox escalation API (write/edit)', () => { id: 'agent-fs-esc', session: { header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - events: [{ type: 'turn/start', data: { turn: 1 } }, ...events], + snapshotEvents: () => [{ type: 'turn/start', data: { turn: 1 } }, ...events], append: (type: string, data: Record) => { events.push({ type, data }) }, }, } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index ed126cc02e..1f1cdf0753 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as commandGoal from '@deepseek-ai/dsh-command-goal' @@ -56,18 +56,18 @@ async function harness(): Promise { } /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */ -function domainEvents(session: Session): readonly Session['events'][number][] { +function domainEvents(session: Session): readonly SessionEvent[] { const lifecycle = new Set() - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type !== 'command/run' && event.type !== 'command/done') continue lifecycle.add(event.seq) // The zero-step wrap around a lifecycle event is bookkeeping too. - const before = session.events[event.seq - 1] - const after = session.events[event.seq + 1] + const before = session.snapshotEvents()[event.seq - 1] + const after = session.snapshotEvents()[event.seq + 1] if (before?.type === 'turn/start') lifecycle.add(before.seq) if (after?.type === 'turn/end') lifecycle.add(after.seq) } - return session.events.filter(event => !lifecycle.has(event.seq)) + return session.snapshotEvents().filter(event => !lifecycle.has(event.seq)) } /** Execute `/goal` through the same registry boundary as a UI adapter. */ diff --git a/packages/goal/goal-round-driver/src/invariant.ts b/packages/goal/goal-round-driver/src/invariant.ts index 6f072140f4..22ca5c39a1 100644 --- a/packages/goal/goal-round-driver/src/invariant.ts +++ b/packages/goal/goal-round-driver/src/invariant.ts @@ -61,7 +61,7 @@ function validateEvent( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { const prior: SessionEvent[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateEvent(prior, event, fail) prior.push(event) } @@ -70,7 +70,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - validateEvent(session.events, event, fail) + validateEvent(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index f92db57dbf..d2749378bf 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -199,7 +199,7 @@ describe('same-session goal driving', () => { }) expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] - for (const event of test.agent.session.events) { + for (const event of test.agent.session.snapshotEvents()) { // Round zero is a durable goal state change; positive rounds are the // admitted continuation prompts this test counts. if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) { @@ -209,7 +209,7 @@ describe('same-session goal driving', () => { expect(rounds).toEqual([1, 2]) expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') - expect(test.agent.session.events.flatMap(event => + expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -265,7 +265,7 @@ describe('same-session goal driving', () => { message: 'Goal round was rejected before entering its step.', }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { @@ -302,7 +302,7 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) // No admitted continuation round reached the model; goal state changes are // represented by their own durable event. - expect(test.agent.session.events.some(event => event.type === 'user/message' + expect(test.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) @@ -330,7 +330,7 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') expect(requestText(test.adapter.requests[0]!)).not.toContain('') expect(requestText(test.adapter.requests[1]!)).toContain('') - expect(test.agent.session.events.flatMap(event => + expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -367,7 +367,7 @@ describe('same-session goal driving', () => { const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 }) - const admitted = test.agent.session.events.find(event => event.type === 'user/message' + const admitted = test.agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' ? admitted.data.source.revision @@ -753,7 +753,7 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('leaves round-zero goal context to the ordinary pre-step chain', async () => { @@ -927,7 +927,7 @@ describe('same-session goal driving', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } })) await handle.agent.whenIdle() - const closed = handle.agent.session.events.findLast(event => event.type === 'turn/end') + const closed = handle.agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') if (closed?.type !== 'turn/end') throw new Error('expected a closed turn') await handle.dispose() const warn = vi.spyOn(test.ctx.logger, 'warn') @@ -1032,7 +1032,7 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('ignores session events without an exact owning agent and retires disposed agent state', async () => { diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 31ac8e2f67..3d8e6a46f0 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -43,7 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): GoalFoldState => { const state = emptyGoalFoldState() - for (const event of session.events) applyChecked(state, event, fail) + for (const event of session.snapshotEvents()) applyChecked(state, event, fail) states.set(session, state) return state } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7deb195d19..503265bab4 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -20,7 +20,7 @@ interface StubAgent { /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { - return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 + return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } /** Mirror the public Agent.inject contract for domain tests. */ @@ -109,8 +109,8 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['goal/change']) - const context = session.events[0] + expect(session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) + const context = session.snapshotEvents()[0] expect(context?.type).toBe('goal/change') if (context?.type !== 'goal/change') throw new Error('expected durable goal change') const change = decodeGoalChange(context.data) @@ -118,7 +118,7 @@ describe('GoalService creation and replay', () => { expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) expect(agent.inbox.nextStep).toEqual([]) expect(session.deriveMessages()).toEqual([]) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() }) @@ -170,7 +170,7 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) - const resumed = stubAgent('seeded-goal', first.session.events) + const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) ctx.agents.register(resumed.agent) expect(ctx.goals.get(resumed.agent)).toMatchObject({ id: created.id, @@ -210,20 +210,20 @@ describe('GoalService creation and replay', () => { expect(ctx.goals.get(agent)?.activation).toBe('disarmed') goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) - expect(() => foldGoal(session.events)).not.toThrow() + expect(() => foldGoal(session.snapshotEvents())).not.toThrow() }) it('lets a lifecycle owner disarm without writing a durable revision', async () => { const { ctx, agent, session } = await harness() const goal = ctx.goals.create(agent, { objective: 'survive driver reload' }) - const before = session.events.length + const before = session.snapshotEvents().length expect(ctx.goals.disarm(agent)).toMatchObject({ id: goal.id, revision: goal.revision, phase: 'active', activation: 'disarmed', }) - expect(session.events).toHaveLength(before) + expect(session.snapshotEvents()).toHaveLength(before) expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) }) @@ -385,7 +385,7 @@ describe('GoalService mutations', () => { const tombstone = ctx.goals.clear(agent, goal) expect(tombstone).toEqual({ id: goal.id, revision: 2 }) expect(ctx.goals.get(agent)).toBeUndefined() - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0, lastRef: tombstone }) expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' })) const next = ctx.goals.create(agent, { objective: 'fresh' }) expect(next.id).not.toBe(goal.id) @@ -401,12 +401,12 @@ describe('GoalService mutations', () => { expect(goal.updatedAt).toBe(100) vi.setSystemTime(80) ctx.goals.clear(agent, goal) - const clear = session.events + const clear = session.snapshotEvents() .filter(event => event.type === 'goal/change') .map(event => event.type === 'goal/change' ? decodeGoalChange(event.data) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) - expect(() => foldGoal(session.events)).not.toThrow() + expect(() => foldGoal(session.snapshotEvents())).not.toThrow() vi.useRealTimers() }) @@ -427,11 +427,11 @@ describe('GoalService mutations', () => { goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'goal/change', 'goal/change', 'goal/change', ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) - expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) it('publishes a mutation consistently to a reentrant session observer', async () => { @@ -451,7 +451,7 @@ describe('GoalService mutations', () => { expect(observed).toEqual(created) expect(ctx.goals.get(stub.agent)).toEqual(created) - expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) + expect(foldGoal(stub.session.snapshotEvents())).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) it('does not delegate goal persistence to agent injection', async () => { @@ -468,7 +468,7 @@ describe('GoalService mutations', () => { revision: 1, }) expect(stub.agent.inbox.nextStep).toEqual([]) - expect(stub.session.events.map(event => event.type)).toEqual(['goal/change']) + expect(stub.session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) }) it('observes an external goal change and disarms local activation', async () => { @@ -528,7 +528,7 @@ describe('goal replay validation', () => { function oneChange(change: GoalChangeMeta) { const session = Session.create(SessionId(`validation-${Math.random()}`)) appendChange(session, change) - return session.events + return session.snapshotEvents() } function mutation( @@ -559,7 +559,7 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('inbox-independent-change')) appendChange(session, change) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) const message = createUserMessage({ content: [{ type: 'text', text: 'unrelated pending context' }], source: { kind: 'plugin', plugin: 'test' }, @@ -567,14 +567,14 @@ describe('goal replay validation', () => { const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) }) function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { const session = Session.create(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) appendChange(session, second) - return foldGoal(session.events) + return foldGoal(session.snapshotEvents()) } it('ignores unrelated metadata and non-goal round sources', () => { @@ -585,7 +585,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'other' }], source: { kind: 'plugin', plugin: 'test' }, })) - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) session.append('turn/start', { turn }) @@ -593,14 +593,14 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) }) it('rejects rounds attributed to another goal', () => { const change = snapshotChange() const session = Session.create(SessionId('other-goal-round'), oneChange(change)) appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) - expect(() => foldGoal(session.events)).toThrow('not the next admitted round') + expect(() => foldGoal(session.snapshotEvents())).toThrow('not the next admitted round') }) it('rejects unsupported versions, operations, and extra top-level fields', () => { @@ -676,7 +676,7 @@ describe('goal replay validation', () => { appendRound(session, base.goal, 2) appendChange(session, { ...paused, roundsStarted: 2 }) appendChange(session, exhausted) - expect(() => foldGoal(session.events)).toThrow('exhausted round budget') + expect(() => foldGoal(session.snapshotEvents())).toThrow('exhausted round budget') }) it('rejects invalid clear continuity and goal id reuse', () => { @@ -700,7 +700,7 @@ describe('goal replay validation', () => { appendChange(completedSession, base) appendChange(completedSession, complete) appendChange(completedSession, sameCurrentId) - expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(completedSession.snapshotEvents())).toThrow('fresh active revision-one') const second = snapshotChange({ goal: { ...base.goal, id: GoalId('goal-second') }, @@ -714,7 +714,7 @@ describe('goal replay validation', () => { appendChange(nonAdjacentReuse, second) appendChange(nonAdjacentReuse, secondComplete) appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) - expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(nonAdjacentReuse.snapshotEvents())).toThrow('fresh active revision-one') const clear: GoalChangeMeta = { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11, @@ -723,7 +723,7 @@ describe('goal replay validation', () => { appendChange(clearedSession, base) appendChange(clearedSession, clear) appendChange(clearedSession, sameCurrentId) - expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(clearedSession.snapshotEvents())).toThrow('fresh active revision-one') }) it('rejects non-positive goal round sources', () => { @@ -735,7 +735,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(() => foldGoal(session.events)).toThrow('goal message source is invalid') + expect(() => foldGoal(session.snapshotEvents())).toThrow('goal message source is invalid') }) it('rejects malformed snapshots, refs, counters, and timestamps', () => { @@ -782,7 +782,7 @@ describe('goal replay validation', () => { clearedAt: 20, } appendChange(session, clear) - expect(foldGoal(session.events)).toEqual({ + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0, lastRef: { id: change.goal.id, revision: 2 }, }) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index f9ef917584..4dd473db31 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -136,7 +136,7 @@ describe('goal projection unit', () => { })) expect(bench.tailValues().goal).toBeNull() - expect(foldGoal(bench.session.events).goal).toBeUndefined() + expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined() }) it('retains strict replay failures without throwing from the projection drive', () => { diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index bc4ae8faaf..29a820c8df 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -30,7 +30,7 @@ function openTurnEvents( ctx: Context, agent: Agent, ): Pick { - const events = agent.session.events + const events = agent.session.snapshotEvents() const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary === undefined || boundary.openTurnStartSeq === null) { reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 86765f87c8..ab544d1b9c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -49,7 +49,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { /** Open one message-triggered turn with its accepted model-visible input. */ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { - const turn = stub.session.events + const turn = stub.session.snapshotEvents() .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 const message = createUserMessage({ @@ -262,7 +262,7 @@ describe('goal tool execution authority', () => { const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) closeTurn(root, originalTurn) const forkId = SessionId('goal-tool-resumed-fork') - const forkSession = Session.create(forkId, root.session.events, { + const forkSession = Session.create(forkId, root.session.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id: forkId, createdAt: Date.now(), diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index 58369c16b7..b324c85096 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), @@ -340,7 +340,7 @@ describe('fold onto the downstream decision', () => { expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. - const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.message.content[0].isError)).toBe(true) expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'nope' }]) }) @@ -364,7 +364,7 @@ describe('fold onto the downstream decision', () => { const found = reminders(agent) expect(found).toHaveLength(1) expect(found[0]!.text).toContain('repeating the exact same tool call') - const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'replaced' }]) }) }) diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index cee3331842..660241f8fd 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -74,7 +74,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): HookTrace => { const trace: HookTrace = { openTurn: null, pending: new Map() } traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null const transition = validateHookEvent(trace, event, fail) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index 18f1383938..a402f8ae02 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,7 +12,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) - const ev = [...session.events].find(e => e.type === 'hook/invoked') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) @@ -25,7 +25,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) - const ev = [...session.events].find(e => e.type === 'hook/invoked') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) } @@ -37,7 +37,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) - const full = [...session.events].find(e => e.type === 'hook/result') + const full = [...session.snapshotEvents()].find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) } @@ -48,7 +48,7 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) - const sparse = [...session2.events].find(e => e.type === 'hook/result') + const sparse = [...session2.snapshotEvents()].find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) expect('stderrSummary' in sparse.data).toBe(false) @@ -63,7 +63,7 @@ describe('hook/* session events', () => { // An explicit decision wins over the continue:false fallback. appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) - const decisions = [...session.events] + const decisions = [...session.snapshotEvents()] .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) @@ -75,7 +75,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) - const ev = [...session.events].find(e => e.type === 'hook/result') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') } @@ -87,7 +87,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) - const ev = [...session.events].find(e => e.type === 'hook/result') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) } @@ -98,8 +98,8 @@ describe('hook/* session events', () => { appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' }) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) - const invoked = [...session.events].find(e => e.type === 'hook/invoked') - const result = [...session.events].find(e => e.type === 'hook/result') + const invoked = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') + const result = [...session.snapshotEvents()].find(e => e.type === 'hook/result') expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1') expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') }) diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index af323d9b63..b92900e01c 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -73,7 +73,7 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } /** diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 4be08bb348..a6e032d353 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -54,7 +54,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index bac95b7eed..200de375ba 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -56,7 +56,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 7fed07518a..6aad28fff0 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -44,7 +44,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index ff5d60a7cb..7bce241224 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -35,7 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } const source = event.data.sourceEventSeq - const sourceEvent = source === undefined ? undefined : session.events[source] + const sourceEvent = source === undefined ? undefined : session.eventAt(source) if (source !== undefined && (event.data.kind !== 'success' || !Number.isSafeInteger(source) || source < 0 || source >= event.seq @@ -46,7 +46,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } } for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(session, event) + for (const event of session.snapshotEvents()) validateEvent(session, event) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index a95ee024dc..655cdc5462 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -33,7 +33,7 @@ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scop /** The lifecycle slice of one agent's log (boundary markers stripped). */ function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> { - return agent.session.events + return agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => ({ type: event.type, data: event.data })) } @@ -316,7 +316,7 @@ describe('CommandRuntime', () => { // The execution's pairing id is the logged one (RPC-level correlation). expect(execution?.commandId).toBe(ids[0]) // Direct log-only appends: no turn is opened for the pair on an idle log. - expect(agent.session.events.map(event => event.type)).toEqual([ + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'command/done', ]) }) @@ -354,7 +354,7 @@ describe('CommandRuntime', () => { await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) - const run = agent.session.events.find(event => event.type === 'command/run') + const run = agent.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type).toBe('command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) }) @@ -428,7 +428,7 @@ describe('CommandRuntime', () => { const signal = new AbortController().signal await ctx.commands.execute(agent, 'not a command', [], signal) await ctx.commands.execute(agent, '/missing', [], signal) - expect(agent.session.events).toEqual([]) + expect(agent.session.snapshotEvents()).toEqual([]) }) it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => { @@ -437,7 +437,7 @@ describe('CommandRuntime', () => { ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) - expect(agent.session.events.map(event => event.type)).toEqual([ + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) }) diff --git a/packages/interaction/permission-presets/src/invariant.ts b/packages/interaction/permission-presets/src/invariant.ts index b6f66adb1c..dee9db2451 100644 --- a/packages/interaction/permission-presets/src/invariant.ts +++ b/packages/interaction/permission-presets/src/invariant.ts @@ -21,7 +21,7 @@ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure /** Install validation that loaded and newly appended preset events remain resolvable. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(ctx, event, fail) + for (const event of session.snapshotEvents()) validateEvent(ctx, event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 065a7ea155..3da5749e2b 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -151,7 +151,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') - expect(session.events.map(e => [e.type, e.data])).toEqual([ + expect(session.snapshotEvents().map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], @@ -162,7 +162,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-noop') ctx.permissionPresets.set(session, 'workspace-write') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => { @@ -173,7 +173,7 @@ describe('PermissionPresetService', () => { // the changed knob. session.append('sandbox/mode', { mode: 'read-only' }) ctx.permissionPresets.set(session, 'danger-full-access') - const tail = session.events.slice(4) + const tail = session.snapshotEvents().slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], @@ -208,7 +208,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) }) @@ -217,7 +217,7 @@ describe('new-session default', () => { it('pins the current setting into each new session without changing earlier sessions', async () => { const ctx = await mountedStore() const first = ctx.sessions.create(SessionId('first')) - expect(first.events.map(event => [event.type, event.data])).toEqual([ + expect(first.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'workspace-write' }], ['sandbox/mode', { mode: 'workspace-write' }], ['approval/policy', { policy: 'ask' }], @@ -230,7 +230,7 @@ describe('new-session default', () => { const second = ctx.sessions.create(SessionId('second')) expect(ctx.permissionPresets.current(first)).toBe('workspace-write') expect(ctx.permissionPresets.current(second)).toBe('danger-full-access') - expect(second.events.map(event => event.type)).toEqual([ + expect(second.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -243,9 +243,9 @@ describe('new-session default', () => { const legacy = freshSession('legacy-source') legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.snapshotEvents() }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + expect(resumed.snapshotEvents().slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -257,7 +257,7 @@ describe('new-session default', () => { }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - expect(resumed.events.map(event => event.type)).toEqual([ + expect(resumed.snapshotEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -274,10 +274,10 @@ describe('new-session default', () => { }) ctx.provide('approval', { config: { policy: 'ask' } }) const existing = ctx.sessions.create(SessionId('existing-before-permission')) - expect(existing.events).toEqual([]) + expect(existing.snapshotEvents()).toEqual([]) await ctx.plugin(PermissionPresetService, {}) - expect(existing.events.map(event => event.type)).toEqual([ + expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) expect(ctx.permissionPresets.current(existing)).toBe('workspace-write') @@ -302,7 +302,7 @@ describe('new-session default', () => { // The remount sweep must read the folded knob events instead of treating // the session as fresh; no default preset events may overwrite the // overrides (read-only + never matches no preset table entry). - expect(existing.events.map(event => event.type)).toEqual([ + expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'sandbox/mode', 'approval/policy', ]) expect(ctx.permissionPresets.current(existing)).toBe(CUSTOM_PRESET) @@ -313,8 +313,8 @@ describe('new-session default', () => { const partial = freshSession('partial-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) partial.append('approval/policy', { policy: 'ask' }) - const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) - expect(resumed.events.at(-1)).toMatchObject({ + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.snapshotEvents() }) + expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'permission/preset', data: { preset: 'workspace-write' }, }) @@ -322,17 +322,17 @@ describe('new-session default', () => { const custom = freshSession('custom-source') custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) - const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.snapshotEvents() }) expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) - expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + expect(unmatched.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { const ctx = await mountedStore({ approvalDefault: undefined }) const partial = freshSession('approval-fallback-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) - const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) - expect(resumed.events.at(-1)).toMatchObject({ + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.snapshotEvents() }) + expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' }, }) diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index f382d4033d..53022e9089 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -99,7 +99,7 @@ describe('/permission command', () => { text: 'The approval policy changed from "ask" to "never" (changed by the user).', }], }) - const run = session.events.find(event => event.type === 'command/run') + const run = session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) }) @@ -111,13 +111,13 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const { agent } = await agentFor(ctx, session) - const before = session.events.filter(event => + const before = session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', [], new AbortController().signal) // The error text carries the same no-self-labelling rule as the success @@ -127,7 +127,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => + expect(session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 2608643ca0..93c4a605ab 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -221,7 +221,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.events)) { + if (!hasOpenTurn(session.snapshotEvents())) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -257,7 +257,7 @@ export class ApprovalService extends Service { * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.events) + return effectiveApprovalPolicy(session.snapshotEvents()) } /** diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index bf3ca8d18d..8ff7a9953e 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -64,7 +64,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): ApprovalTrace => { const trace: ApprovalTrace = { openTurn: null, pending: new Set() } traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null const transition = validateApprovalEvent(trace, event, fail) diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index eb0484a1db..868ba4f384 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -11,7 +11,7 @@ import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPol /** * A minimal Agent stand-in — the service only reaches `agent.session.append` - * and folds `.events`. Seeded inside an open turn by default (request()'s + * and folds `snapshotEvents()`. Seeded inside an open turn by default (request()'s * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ @@ -19,7 +19,7 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { - events: seed, + snapshotEvents: () => seed, append: (type: string, data: Record) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent @@ -128,9 +128,9 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') - const audit = session.events.filter(event => event.type.startsWith('approval/')) - const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data.id).toBe(asked?.data.id) expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append')) @@ -151,9 +151,9 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') - const audit = session.events.filter(event => event.type.startsWith('approval/')) - const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append')) @@ -164,7 +164,7 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start' }], + snapshotEvents: () => [{ type: 'turn/start' }], append: () => { throw failure }, }, } as unknown as Agent @@ -366,11 +366,11 @@ describe('approval policy (the approval/policy fold)', () => { it('folds to the last event, or undefined without one', () => { const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.events)).toBeUndefined() + expect(effectiveApprovalPolicy(session.snapshotEvents())).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.events)).toBe('ask') - expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) + expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('ask') + expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) it('rejects a policy outside the closed vocabulary before appending', () => { @@ -409,8 +409,8 @@ describe('approval policy (the approval/policy fold)', () => { await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') expect(consulted).not.toHaveBeenCalled() // The audit pair still lands on the session log. - expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1) - expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1) + expect(session.snapshotEvents().filter(e => e.type === 'approval/asked')).toHaveLength(1) + expect(session.snapshotEvents().filter(e => e.type === 'approval/decided')).toHaveLength(1) }) it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { @@ -458,7 +458,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.events)).toBe('never') + expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 7b454bcc4e..8655f6d661 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -147,9 +147,10 @@ function validateStarted( /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const [index, event] of session.events.entries()) { - if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) - else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail) + const events = session.snapshotEvents() + for (const [index, event] of events.entries()) { + if (event.type === 'llm/retry') validateRetry(events.slice(0, index), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(events.slice(0, index), event, fail) } } @@ -160,8 +161,8 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - if (event.type === 'llm/retry') validateRetry(session.events, event, fail) - else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail) + if (event.type === 'llm/retry') validateRetry(session.snapshotEvents(), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index b4bef4c239..1ce91a8b29 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -112,7 +112,7 @@ describe('real Loader composition', () => { await agent.whenIdle() expect(adapter.requests).toBe(2) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'recovered' }], diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 30ffd662e9..1516ff20a4 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -215,7 +215,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data)) + expect(agent.session.snapshotEvents().filter(item => item.type === 'step/start').map(item => item.data)) .toEqual([{ turn: 1, step: 1 }]) expect(agent.session.deriveMessages().at(-1)).toEqual({ id: expect.any(String) as unknown, @@ -250,7 +250,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message').map(event => ({ turn: event.data.turn, step: event.data.step, }))).toEqual([{ turn: 1, step: 1 }]) @@ -286,14 +286,14 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(500) await idle - const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') - const failedChunks = agent.session.events.filter(event => + const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + const failedChunks = agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && retryEvent !== undefined && event.seq < retryEvent.seq, ) expect(failedChunks).toHaveLength(7) - const assistantMessages = agent.session.events.filter(event => event.type === 'assistant/message') + const assistantMessages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') expect(assistantMessages.map(event => ({ turn: event.data.turn, step: event.data.step, @@ -301,7 +301,7 @@ describe('provider-routed retry policy', () => { expect(failedChunks.every(event => !assistantMessages[0]?.sourceEventSeqs?.includes(event.seq), )).toBe(true) - expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'tool/call')).toBe(false) expect(toolExecutions).toBe(0) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', @@ -338,8 +338,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } }, }) @@ -394,7 +394,7 @@ describe('provider-routed retry policy', () => { rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await rejectedIdle expect(rejected.requests).toHaveLength(1) - expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(rejectedAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it('uses local jittered backoff when always mode receives an over-cap Retry-After', async () => { @@ -432,7 +432,7 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -451,8 +451,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - const end = agent.session.events.at(-1) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } }, @@ -480,7 +480,7 @@ describe('provider-routed retry policy', () => { const normalIdle = waitForIdle(context, normalAgent) normalAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } })) await normalIdle - expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(normalAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { provider: 'other', @@ -566,7 +566,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => ({ provider: event.data.provider, retry: event.data.retry, }))).toEqual([ @@ -677,7 +677,7 @@ describe('provider-routed retry policy', () => { await vi.runAllTimersAsync() await idle - const events = agent.session.events.filter(event => event.type === 'llm/retry') + const events = agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(adapter.requests).toHaveLength(5) expect(events.map(event => ({ provider: event.data.provider, @@ -721,7 +721,7 @@ describe('provider-routed retry policy', () => { const retriedContext = JSON.stringify(adapter.requests[1]?.messages) expect(retriedContext).not.toContain(diagnostic) expect(retriedContext).not.toContain('discarded partial output') - expect(agent.session.events.some(event => + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry' && event.data.failure.message === diagnostic, )).toBe(true) }) @@ -743,7 +743,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it.each([ @@ -794,7 +794,7 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(60_000) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) @@ -835,7 +835,7 @@ describe('provider-routed retry policy', () => { expect(order[0]).toBe('downstream') expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it('drains delegated recovery before turn cancellation reaches idle', async () => { @@ -873,7 +873,7 @@ describe('provider-routed retry policy', () => { expect(order).toEqual(['downstream', 'idle']) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -910,7 +910,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -967,7 +967,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -996,8 +996,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -1020,7 +1020,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 7591f8fdf2..c9b8c7eaca 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -103,10 +103,10 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server).toBeDefined() expect(server?.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start') + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('connected after retry') }) @@ -132,16 +132,16 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) - const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') - expect(agent.session.events.filter(event => + const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && retryEvent !== undefined && event.seq < retryEvent.seq, )).toHaveLength(failedChunkCount) - expect(agent.session.events.filter(event => event.type === 'assistant/message') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('recovered response') }) @@ -161,12 +161,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['EMPTY_RESPONSE']) - expect(agent.session.events.filter(event => event.type === 'assistant/message') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -188,12 +188,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(1) - expect(agent.session.events.filter(event => + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && event.data.turn === 1, )).toHaveLength(3) - expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, }) @@ -215,7 +215,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success']) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') }, 10_000) @@ -233,9 +233,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - const end = agent.session.events.at(-1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index e33c70b1df..008c19f64a 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -206,9 +206,9 @@ export class TokenMeter extends Service { this.states.set(session, state) } - while (state.consumedEvents < session.events.length) { + while (state.consumedEvents < session.seq) { // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log - const event = session.events[state.consumedEvents]! + const event = session.eventAt(state.consumedEvents)! this._foldEvent(session, state, event) state.consumedEvents += 1 } @@ -314,7 +314,7 @@ export class TokenMeter extends Service { seen.add(seq) // Session construction validates contiguous seqs, and the explicit // earlier-than-assistant check above therefore guarantees existence. - const source = session.events[seq] + const source = session.eventAt(seq) // oxlint-disable-next-line typescript/no-non-null-assertion const sourceEvent = source! if (sourceEvent.type !== 'assistant/chunk') { diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 84f1dd1788..198a660992 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -219,7 +219,7 @@ describe('TokenMeter pricing', () => { const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens) - expect(result.logRevision).toBe(session.events.length) + expect(result.logRevision).toBe(session.snapshotEvents().length) expectSurfaceTotal(result) }) @@ -404,7 +404,7 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'new tail' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const seeded = Session.create(SessionId('surface-seeded'), original.events) + const seeded = Session.create(SessionId('surface-seeded'), original.snapshotEvents()) const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) @@ -417,15 +417,15 @@ describe('replay anchors and surface folds', () => { }), { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] }) const after = service.measure(seeded) expect(after.nodes).toHaveLength(2) - expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1) - expect(after.logRevision).toBe(seeded.events.length) + expect(after.nodes[0]!.seq).toBe(seeded.snapshotEvents().length - 1) + expect(after.logRevision).toBe(seeded.snapshotEvents().length) expect(Object.isFrozen(after.nodes)).toBe(true) expect(Object.isFrozen(after.nodes[0])).toBe(true) expect(after.surfaceDeltaTokens).toBeLessThan(0) expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) // The earlier snapshot still reports the log it measured: seed + boundary. - expect(before.logRevision).toBe(original.events.length + 1) + expect(before.logRevision).toBe(original.snapshotEvents().length + 1) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -437,7 +437,7 @@ describe('replay anchors and surface folds', () => { provenance: 'empty', }) const measurement = meter().measure(session) - const assistant = session.events.find(event => event.type === 'assistant/message')! + const assistant = session.snapshotEvents().find(event => event.type === 'assistant/message')! expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0, heuristicTokens: 0 }]) expect(measurement.surfaceTokens).toBe(0) expectSurfaceTotal(measurement) @@ -650,7 +650,7 @@ describe('malformed replay and listener lifecycle', () => { source: { kind: 'user' }, }), { surfaceOp: 'append' }) appendHeader(session, header('deepseek-v4-flash')) - const head = session.events[0]!.seq + const head = session.snapshotEvents()[0]!.seq session.append('assistant/message', { turn: 1, step: 1, diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 634efadfb5..72a319fc45 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -28,7 +28,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended plan-mode state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const seed = (session: Session): void => { - for (const event of session.events) validateEvent(event, fail) + for (const event of session.snapshotEvents()) validateEvent(event, fail) } for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index a40df90638..0cbb6649d7 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -86,7 +86,7 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const header = findEvent(log, 'request/header') expect(planMode.seq).toBeLessThan(header.seq) @@ -114,14 +114,14 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(planActive(ctx, agent)).toBe(false) - const first = findEvent(agent.session.events, 'request/header') + const first = findEvent(agent.session.snapshotEvents(), 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.planMode.set(agent, true) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() expect(planActive(ctx, agent)).toBe(true) const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notices).toHaveLength(1) @@ -163,7 +163,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section) expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools) expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const nextIdle = waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } })) @@ -172,7 +172,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(3) expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section) expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) - const log = agent.session.events + const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' && event.data.turn === 1 && event.data.step === 1) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index f29e15d3fc..cd53724e34 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -142,7 +142,7 @@ function header(session: Session): void { } function noticeTexts(session: Session): string[] { - return session.events + return session.snapshotEvents() .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join('')) } @@ -205,19 +205,19 @@ describe('resolveConfig', () => { describe('foldPlanMode', () => { it('folds an empty log to inactive and takes the last plan/mode otherwise', () => { const session = Session.create(SessionId('fold')) - expect(foldPlanMode(session.events)).toBe(false) + expect(foldPlanMode(session.snapshotEvents())).toBe(false) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) session.append('plan/mode', { active: true }) - expect(foldPlanMode(session.events)).toBe(true) + expect(foldPlanMode(session.snapshotEvents())).toBe(true) }) it('folds a prefix when `end` is given', () => { const session = Session.create(SessionId('fold-prefix')) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) - expect(foldPlanMode(session.events, 1)).toBe(true) - expect(foldPlanMode(session.events, 0)).toBe(false) + expect(foldPlanMode(session.snapshotEvents(), 1)).toBe(true) + expect(foldPlanMode(session.snapshotEvents(), 0)).toBe(false) }) }) @@ -281,14 +281,14 @@ describe('ctx.planMode: get/set', () => { const ctx = await setup() const agent = await agentWithSession(ctx, 'agent-idle') expect(ctx.planMode.set(agent, true)).toBe('committed') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) // Immediately reversible, still without a boundary. expect(ctx.planMode.set(agent, false)).toBe('committed') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) // A later boundary finds nothing pending — no double append. await boundary(ctx, agent, 'step-start') - expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'plan/mode')).toHaveLength(2) }) it('a between-turns reversal of a mid-turn pending intent cancels without logging', async () => { @@ -299,7 +299,7 @@ describe('ctx.planMode: get/set', () => { closeTurn(agent.session) // Back to the logged state: the pending intent clears, nothing lands. expect(ctx.planMode.set(agent, false)).toBe('cancelled') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(ctx.planMode.get(agent)).toEqual({ active: false }) }) @@ -319,7 +319,7 @@ describe('the boundary flush', () => { const service = ctx.planMode as unknown as { onBoundary(session: Session): void } expect(() => { service.onBoundary(agent.session) }).not.toThrow() - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) it('flushes from pre-step before the following step/start', async () => { @@ -328,7 +328,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'pre-step') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) @@ -343,7 +343,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) await fiber.dispose() await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) it('flushes at the between-step seam too', async () => { @@ -351,7 +351,7 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -362,7 +362,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -403,7 +403,7 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -430,7 +430,7 @@ describe('the boundary flush', () => { expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) agent.session.append = original await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) @@ -674,7 +674,7 @@ describe('/plan', () => { expect(enteringSteer).not.toHaveBeenCalled() await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) - expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(entering.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const active = await agentWithSession(ctx, 'active-plan-command', { active: true }) openTurn(active.session) @@ -698,10 +698,10 @@ describe('/plan', () => { const agent = await agentWithSession(ctx, 'idle-plan-command') expect((await ctx.commands.execute(agent, '/plan', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' }) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) it('steers image attachments with or without text and refuses them on /plan off', async () => { @@ -848,7 +848,7 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }]) } expect(asked).toHaveLength(0) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('degrades to the manual exit when no user-questions seam is composed', async () => { @@ -857,7 +857,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions channel is available to review the plan; ask the user to switch the session mode instead' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => { @@ -865,7 +865,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions answerer accepted the request' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => { @@ -885,7 +885,7 @@ describe('exit_plan_mode', () => { text: "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", }]) expect(ask).not.toHaveBeenCalled() - expect(foldPlanMode(child.session.events)).toBe(true) + expect(foldPlanMode(child.session.snapshotEvents())).toBe(true) }) it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => { @@ -897,10 +897,10 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]) // Boundary-applied, not a direct append: the fold stays plan until the // step's end, so the plan policy covers any remaining call of the SAME batch. - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things') @@ -950,7 +950,7 @@ describe('exit_plan_mode', () => { question: 'Approve this plan and leave plan mode?', detail: plan, }) - expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ + expect(agent.session.snapshotEvents().find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ name: EXIT_PLAN_MODE, arguments: { plan }, isError: false, @@ -965,12 +965,12 @@ describe('exit_plan_mode', () => { // Calls of the SAME assistant response were requested under the existing // plan-shaped header. Pending state shapes only the proposed next // assembly; the accepted boundary then commits the matching durable fold. - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) const assembly = await ctx.systemPrompt.assemble({ agent }) expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true) expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('') await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('') @@ -981,7 +981,7 @@ describe('exit_plan_mode', () => { header(agent.session) await callExit(ctx, agent) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -990,7 +990,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('keep planning without feedback returns the generic corrective error', async () => { @@ -1005,7 +1005,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('requires exactly the single Approve selection', async () => { @@ -1013,7 +1013,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('treats custom text alongside Approve as feedback, not consent', async () => { @@ -1021,7 +1021,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('treats duplicate review answer items as non-consent', async () => { @@ -1035,7 +1035,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('a missing answer item reads as keep-planning', async () => { @@ -1067,7 +1067,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('leaves every other review failure its own message', async () => { @@ -1079,7 +1079,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('forwards the execution abort signal to the review question', async () => { @@ -1119,7 +1119,7 @@ describe('exit_plan_mode', () => { const result = await pending expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => { @@ -1128,7 +1128,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('presents the call as a generic card titled by the plan first heading', async () => { @@ -1179,6 +1179,6 @@ describe('HMR disposal', () => { expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy') await boundary(ctx, agent, 'step-start') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) }) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index ff41e160a7..316f4b9408 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -155,7 +155,7 @@ describe('plan projection unit', () => { // A second registry over the same log (the cold-read shape): no service // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) - for (const event of bench.session.events) { + for (const event of bench.session.snapshotEvents()) { if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index b7ece833cc..6865a1f59c 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -92,7 +92,7 @@ async function agentOn(ctx: Context, id: string, presetId?: string): Promise - agent.session.events.findLast(event => event.type === 'agent-preset/selected')?.data + agent.session.snapshotEvents().findLast(event => event.type === 'agent-preset/selected')?.data describe('the roster a client reads', () => { it('projects path-free rows, marking the default and carrying published metadata', async () => { diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 32e2c998b7..6f3d5ca654 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -23,7 +23,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended sandbox modes. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(event, fail) + for (const event of session.snapshotEvents()) validateEvent(event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 52172cb09b..a680373163 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -206,7 +206,7 @@ describe('sandbox:policy request context', () => { it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => { const active = session('sess-resume', '/projects/current') setSandboxMode(active, 'workspace-write') - const resumed = Session.create(active.id, active.events, active.header) + const resumed = Session.create(active.id, active.snapshotEvents(), active.header) const ctx = await promptMounted({ mode: 'read-only' }) expect(await policyContext(ctx, resumed)).toContain('workspace-write') @@ -231,7 +231,7 @@ describe('the sandbox/mode session kit', () => { it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { const session = Session.create(SessionId('sess-write')) setSandboxMode(session, 'danger-full-access') - const modeEvents = session.events.filter(e => e.type === 'sandbox/mode') + const modeEvents = session.snapshotEvents().filter(e => e.type === 'sandbox/mode') expect(modeEvents).toHaveLength(1) expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) }) diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 6dc2bb0ace..d0eda965bd 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/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/schedule/schedule/README.md -README.md: 858eca688def615201f3085db023b494505fb065 -README.zh.md: 633b4ff9305b82b93e93e900dc0a35faeebc392e +README.md: 0b2bfaf5d3e22a4a552f35e9bea1a0743bb80bbb +README.zh.md: 603c292b866cbeb0e81dbbe38e39e69310695b85 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 858eca688d..0b2bfaf5d3 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -94,7 +94,7 @@ The package rests on one separation and three commitments: ### Durable state and replay -A normal Session folds its complete event stream. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. Every create record carries a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record also stores `afterSeconds`, an `at` record stores no copy of its submitted offset or local fields, and an `every` record stores `everySeconds` with `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id; an `every` dispatch adds `acceptedAt`, and replay advances directly to the first anchor-aligned target after that decision time. +A normal Session folds its complete event stream. A fork folds only `session.snapshotEvents(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. Every create record carries a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record also stores `afterSeconds`, an `at` record stores no copy of its submitted offset or local fields, and an `every` record stores `everySeconds` with `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id; an `every` dispatch adds `acceptedAt`, and replay advances directly to the first anchor-aligned target after that decision time. ### Time validation diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 633b4ff930..603c292b86 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -94,7 +94,7 @@ Time-context 不是 Schedule 的依赖。官方 Web overlay 挂载 `@deepseek-ai ### 持久状态与回放 -普通会话折叠完整事件流。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 +普通会话折叠完整事件流。fork 只折叠 `session.snapshotEvents(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 ### 时间校验 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 8a619e2e73..d94bb6445d 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -48,7 +48,7 @@ export function apply(ctx: Context): void { const cleanup: OwnerCleanup = agent.ctx.effect(() => { const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { runtime.requestDrive() }) const stopStatus = agent.ctx.on('agent/status', ({ status }) => { - if (status === 'idle' && agent.session.events.some(event => event.type === 'schedule/change')) { + if (status === 'idle' && agent.session.snapshotEvents().some(event => event.type === 'schedule/change')) { runtime.requestDrive() } }) diff --git a/packages/schedule/schedule/src/invariant.ts b/packages/schedule/schedule/src/invariant.ts index 59a9ab6c21..5af5245ade 100644 --- a/packages/schedule/schedule/src/invariant.ts +++ b/packages/schedule/schedule/src/invariant.ts @@ -30,16 +30,16 @@ function validate(events: readonly SessionEvent[], seedLength: number, fail: Inv /** Install replay and pre-append validation for the owned event stream. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - validate(session.events, session.header.seedLength ?? 0, fail) + validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) } ctx.on('session/created', (session) => { - validate(session.events, session.header.seedLength ?? 0, fail) + validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type !== 'schedule/change') return - validate([...session.events, event], session.header.seedLength ?? 0, fail) + validate([...session.snapshotEvents(), event], session.header.seedLength ?? 0, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index e6f4fce72c..b7f45848c5 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -206,7 +206,7 @@ export class ScheduleRuntime { private readFolded(): FoldedSchedules | undefined { try { return foldScheduleEvents( - this.agent.session.events, + this.agent.session.snapshotEvents(), this.agent.session.header.seedLength ?? 0, ) } catch (error: unknown) { diff --git a/packages/schedule/schedule/src/tools.ts b/packages/schedule/schedule/src/tools.ts index a7f4d3e8a7..616d8650fb 100644 --- a/packages/schedule/schedule/src/tools.ts +++ b/packages/schedule/schedule/src/tools.ts @@ -221,7 +221,7 @@ function inputError(error: ScheduleInputError): ScheduleToolError { /** Fold only after a successful preflight, mapping corruption to a stable value. */ function foldForTool(agent: Agent): ReturnType | ScheduleToolError { try { - return foldScheduleEvents(agent.session.events, agent.session.header.seedLength ?? 0) + return foldScheduleEvents(agent.session.snapshotEvents(), agent.session.header.seedLength ?? 0) } catch (error: unknown) { return error instanceof ScheduleLogError ? corruptLogError() : internalError() } diff --git a/packages/schedule/schedule/tests/invariant.spec.ts b/packages/schedule/schedule/tests/invariant.spec.ts index 88e7465260..0896a8b6f9 100644 --- a/packages/schedule/schedule/tests/invariant.spec.ts +++ b/packages/schedule/schedule/tests/invariant.spec.ts @@ -53,17 +53,17 @@ describe('Schedule package invariant', () => { const session = ctx.sessions.create(SessionId('schedule-invariant')) session.append('turn/start', { turn: 1 }) session.append('schedule/change', create('schedule-1')) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) expect(() => session.append('schedule/change', { version: 1, operation: 'delete', id: ScheduleId('missing'), })).toThrow(InvariantError) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) session.append('schedule/change', { version: 1, operation: 'dispatch', id: ScheduleId('schedule-1') }) - expect(session.events).toHaveLength(3) + expect(session.snapshotEvents()).toHaveLength(3) await ctx.fiber.dispose() }) @@ -82,7 +82,7 @@ describe('Schedule package invariant', () => { id: ScheduleId('schedule-every'), acceptedAt: '2026-08-05T12:17:34.000Z', }) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) await ctx.fiber.dispose() }) @@ -117,7 +117,7 @@ describe('Schedule package invariant', () => { }) const fiber = await ctx.plugin(scheduleInvariant) child.append('schedule/change', create('child')) - expect(child.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(child.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await fiber.dispose() await ctx.fiber.dispose() }) diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index d18e35aac9..879b16d0ad 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -129,7 +129,7 @@ describe('Schedule production JSONL restart', () => { await replayed.sessions.flush(replayHandle.agent.session) expect(replayAdapter.requests).toEqual([]) - expect(replayHandle.agent.session.events.filter(event => + expect(replayHandle.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) const replayedStored = await replayed.sessionPersistence.inspect(sessionId) expect(replayedStored.events.filter(event => diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index 609c68a6cd..86b8246c32 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -173,7 +173,7 @@ describe('Schedule timer and admission runtime', () => { await settle() expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.find(event => + expect(test.agent.session.snapshotEvents().find(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toBeDefined() await runtime.dispose() }) @@ -223,7 +223,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.followed).toEqual([]) expect(test.controls.whenIdleCount).toBe(1) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) runtime.requestDrive() await settle() @@ -295,13 +295,13 @@ describe('Schedule timer and admission runtime', () => { ].join('\n'), }]) expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'schedule' }) - const dispatches = test.agent.session.events.filter(event => + const dispatches = test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch') expect(dispatches.map(event => event.data)).toEqual([ { version: 1, operation: 'dispatch', id: 'schedule-fast', acceptedAt: '2026-08-05T12:00:00.000Z' }, { version: 1, operation: 'dispatch', id: 'schedule-slow', acceptedAt: '2026-08-05T12:00:00.000Z' }, ]) - expect(foldScheduleEvents(test.agent.session.events).active).toEqual([ + expect(foldScheduleEvents(test.agent.session.snapshotEvents()).active).toEqual([ expect.objectContaining({ id: 'schedule-fast', scheduledAt: '2026-08-05T12:05:00.000Z' }), expect.objectContaining({ id: 'schedule-slow', scheduledAt: '2026-08-05T12:09:00.000Z' }), ]) @@ -370,7 +370,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.controls.releaseCount).toBe(1) expect(test.followed).toEqual([]) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'delete' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'delete' }) runtime.requestDrive() await settle() expect(test.followed).toEqual([]) @@ -406,9 +406,9 @@ describe('Schedule timer and admission runtime', () => { appendAfter(unreadable, 'schedule-1', 1, Date.now() - 1_000) unreadable.controls.onReserve = () => { unreadable.controls.onReserve = undefined - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw new Error('became unreadable') }, + value: () => { throw new Error('became unreadable') }, }) } const unreadableRuntime = runtimeFor(unreadable) @@ -429,7 +429,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) await runtime.dispose() @@ -460,7 +460,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) runtime.requestDrive() await settle() @@ -487,7 +487,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => ( + expect(test.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' )).map(event => event.data)).toEqual([{ version: 1, @@ -495,7 +495,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { id: 'schedule-first', acceptedAt: '2026-08-05T12:00:00.000Z', }]) - expect(foldScheduleEvents(test.agent.session.events).active).toEqual([ + expect(foldScheduleEvents(test.agent.session.snapshotEvents()).active).toEqual([ expect.objectContaining({ id: 'schedule-first', scheduledAt: '2026-08-05T12:05:00.000Z' }), expect.objectContaining({ id: 'schedule-second', scheduledAt: '2026-08-05T11:55:00.000Z' }), ]) @@ -545,7 +545,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.flushCount).toBe(1) expect(test.followed).toEqual([]) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await runtime.dispose() const departed = await harness() @@ -607,15 +607,15 @@ describe('Schedule runtime failure and teardown boundaries', () => { } await settle() expect(test.followed).toEqual([]) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) }) it('faults on corrupt or unreadable durable state after preflight', async () => { const corrupt = await harness() - Object.defineProperty(corrupt.agent.session, 'events', { + Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), data: { version: 9, operation: 'delete', id: 'schedule-1' }, }], @@ -626,9 +626,9 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(corrupt.followed).toEqual([]) const unreadable = await harness() - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw 'unreadable log' }, + value: () => { throw 'unreadable log' }, }) const unreadableRuntime = runtimeFor(unreadable) unreadableRuntime.start() diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index b8ef0be6f8..4ac5d91717 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -159,7 +159,7 @@ describe('Schedule tool protocol', () => { expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 }))) .toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' }) expect(test.flushes.count).toBe(0) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('creates, lists, marks overdue, deletes, and never reuses an id', async () => { @@ -232,7 +232,7 @@ describe('Schedule tool protocol', () => { expect.objectContaining({ id: 'schedule-1', kind: 'at' }), expect.objectContaining({ id: 'schedule-2', kind: 'at' }), ]) - const changes = test.agent.session.events + const changes = test.agent.session.snapshotEvents() .filter(event => event.type === 'schedule/change' && event.data.operation === 'create') expect(changes.map((change) => { if (change.type !== 'schedule/change' || change.data.operation !== 'create') { @@ -300,7 +300,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be strictly in the future.', }) expect(test.flushes.count).toBe(3) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('returns a range error only after the create preflight', async () => { @@ -312,7 +312,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', }) expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const internal = await harness() const now = vi.spyOn(Date, 'now').mockImplementationOnce(() => { throw new Error('clock unavailable') }) @@ -350,9 +350,9 @@ describe('Schedule tool protocol', () => { describe('Schedule persistence failure boundaries', () => { it('does not fold an unconfirmed corrupt live suffix before preflight succeeds', async () => { const test = await harness() - Object.defineProperty(test.agent.session, 'events', { + Object.defineProperty(test.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), @@ -438,7 +438,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(0) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('does not persist a create cancelled during its first preflight', async () => { @@ -462,7 +462,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('does not persist a delete cancelled during its first preflight', async () => { @@ -485,7 +485,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(3) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')) .toHaveLength(1) expect(value(await execute(test, 'schedule_list', {}))) .toEqual([expect.objectContaining({ id: 'schedule-1' })]) @@ -496,21 +496,21 @@ describe('Schedule persistence failure boundaries', () => { createTest.flushes.outcomes.push('reject') expect(value(await execute(createTest, 'schedule_create', { prompt: 'later', after_seconds: 1 }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'create' }) - expect(createTest.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(createTest.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const deleteTest = await harness() await execute(deleteTest, 'schedule_create', { prompt: 'keep', after_seconds: 1 }) deleteTest.flushes.outcomes.push('reject') expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' }) - expect(deleteTest.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(deleteTest.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) }) it('maps corrupt and unreadable folds for create, list, and delete', async () => { const corrupt = await harness() - Object.defineProperty(corrupt.agent.session, 'events', { + Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), data: { version: 9, operation: 'delete', id: 'schedule-1' }, }], @@ -521,9 +521,9 @@ describe('Schedule persistence failure boundaries', () => { .toMatchObject({ code: 'corrupt_schedule_log' }) const unreadable = await harness() - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw 'unreadable log' }, + value: () => { throw 'unreadable log' }, }) expect(value(await execute(unreadable, 'schedule_list', {}))) .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' }) diff --git a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts index a1e58b11ae..1d61e0ef3a 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts @@ -65,7 +65,7 @@ describe('session-log-download real Loader composition', () => { }) const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' }) - expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done']) expect(session.deriveMessages()).toEqual([]) }) }) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1c4569df4c..f7e4ce043d 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -854,7 +854,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { - return observeSession(session.header, session.events) + return observeSession(session.header, session.snapshotEvents()) } function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 69fbc8a1a9..487b6a9702 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -239,7 +239,7 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { - return { header: session.header, events: session.events } + return { header: session.header, events: session.snapshotEvents() } } function orderedResults( @@ -292,7 +292,7 @@ async function inspectPersisted( function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), - events: session.events.map(event => structuredClone(event)), + events: session.snapshotEvents().map(event => structuredClone(event)), } } diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 8810e1972f..4d56007d63 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -151,7 +151,7 @@ export class SessionObservationReader { session: Session, projectionMode: NonNullable, ): SessionObservation { - const events = Object.freeze([...session.events]) + const events = session.snapshotEvents() const projections = projectionMode === 'none' ? undefined : this.ctx.get('sessionProjections')?.snapshot(session) diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 7dc856ee29..7151714b67 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -18,7 +18,7 @@ function preparedSource( const preparedSession = Session.create(meta.id, [], meta) return { source: 'prepared', - inspection: { meta: preparedSession.header, events: preparedSession.events }, + inspection: { meta: preparedSession.header, events: preparedSession.snapshotEvents() }, revision: SessionPersistenceRevision(`fixture:${meta.id}`), preparedSession, [Symbol.dispose]: dispose, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 369fa3b3ae..77161b935f 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1002,7 +1002,8 @@ describe('session-query exact reads', () => { }).toThrow() Object.assign(snapshot.session, { cwd: '/mutated' }) - expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1) + const logged = session.eventAt(4) + expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) expect(session.header.cwd).toBe('/work') }) @@ -1038,7 +1039,8 @@ describe('session-query exact reads', () => { (result.events[0]!.data as { content: unknown[] }).content = [] }).toThrow() expect(session.header.createdAt).not.toBe(-1) - expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1) + const logged = session.eventAt(1) + expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index 08d07d500b..697adc5951 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -66,7 +66,7 @@ function callerOf(exec: ToolRunContext, ctx: Context): Caller { return { id: agent.session.id, header: agent.session.header, - events: agent.session.events, + events: agent.session.snapshotEvents(), boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 9db2a45da7..eaa4ce7cfe 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -8,6 +8,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type Session, + type SessionEvent, type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' @@ -1213,7 +1214,7 @@ describe('workspace authority and lineage redaction', () => { const appendLegacy = mounted.caller.append.bind(mounted.caller) as unknown as ( type: string, data: unknown, - ) => Session['events'][number] + ) => SessionEvent const secret = appendLegacy( 'context/message', { @@ -2008,7 +2009,7 @@ describe('trace and exact read rendering', () => { expect(text(result)).toContain('Replacement chain: 1') expect(text(result)).toContain('Events cited directly as sources: none') expect(text(result)).toContain('Direct derived events: 1') - expect(text(result)).toContain(new Date(session.events[0]?.time ?? 0).toISOString()) + expect(text(result)).toContain(new Date(session.snapshotEvents()[0]?.time ?? 0).toISOString()) }) it('renders unabridged fenced target JSON and readable semantic or log-only neighbor summaries', async () => { @@ -2040,7 +2041,7 @@ describe('trace and exact read rendering', () => { const appendLegacy = session.append.bind(session) as unknown as ( type: string, data: unknown, - ) => Session['events'][number] + ) => SessionEvent appendLegacy( 'context/message', { content: [{ type: 'text', text: 'after semantic text' }], source: { kind: 'plugin', plugin: 'test' } }, diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index b66c21706c..a472e62f63 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { DeepSeekSessionLogExtension } from './types.ts' export type * from './types.ts' @@ -44,10 +44,13 @@ const acceptanceFolds = new WeakMap() export function acceptedThrough(session: Session): number { const previous = acceptanceFolds.get(session) let throughSeq = previous?.throughSeq ?? -1 - const events = session.events + const length = session.seq const start = previous?.scannedEvents ?? 0 - for (let index = start; index < events.length; index++) { - const event = events[index] as SessionEvent + for (let index = start; index < length; index++) { + const event = session.eventAt(index) + if (event === undefined) { + throw new Error(`session-log-deepseek: missing event ${String(index)} below captured length ${String(length)}`) + } if (event.type !== 'session-log-deepseek/delivery-accepted') continue if (typeof event.data.sessionId !== 'string' || event.data.sessionId.length === 0 || !Number.isSafeInteger(event.data.throughSeq) || event.data.throughSeq < 0 @@ -57,7 +60,7 @@ export function acceptedThrough(session: Session): number { if (event.data.sessionId !== session.id) continue throughSeq = Math.max(throughSeq, event.data.throughSeq) } - acceptanceFolds.set(session, { scannedEvents: events.length, throughSeq }) + acceptanceFolds.set(session, { scannedEvents: length, throughSeq }) return throughSeq } @@ -76,10 +79,9 @@ export function apply(ctx: Context, config: Config): void { if (session === undefined) return undefined const afterSeq = acceptedThrough(session) - const snapshot = session.events - const throughSeq = snapshot.length - 1 + const throughSeq = session.seq - 1 if (throughSeq < 0) return undefined - const suffix = snapshot.slice(afterSeq + 1) + const suffix = session.snapshotEvents(afterSeq + 1) const value: DeepSeekSessionLogExtension = { version: 1, session: session.header, diff --git a/packages/session/session-log-deepseek/src/invariant.ts b/packages/session/session-log-deepseek/src/invariant.ts index e739126ab6..b7c2c848f9 100644 --- a/packages/session/session-log-deepseek/src/invariant.ts +++ b/packages/session/session-log-deepseek/src/invariant.ts @@ -28,7 +28,7 @@ function validateDeliveryAccepted(session: Session, event: SessionEvent<'session /** Validate acceptance watermarks already present in one Session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'session-log-deepseek/delivery-accepted') validateDeliveryAccepted(session, event, fail) } } diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index d473cc7de9..560851a4d8 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -76,7 +76,7 @@ describe('incremental DeepSeek session-log upload', () => { first.session.append('turn/start', { turn: 1 }) const prepared = await first.ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: first.session.id }) await prepared.accept() - const seed = first.session.events + const seed = first.session.snapshotEvents() const resumed = await harness('parent', seed) expect(SessionLogDeepSeek.acceptedThrough(resumed.session)).toBe(0) @@ -110,13 +110,14 @@ describe('incremental DeepSeek session-log upload', () => { { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: id, throughSeq: 0 } }, ] let reads = 0 - const observed = new Proxy(events, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) reads++ - return Reflect.get(target, property, receiver) as unknown + const session = { + id, + get seq() { return events.length }, + eventAt(seq: number) { + reads++ + return events[seq] }, - }) - const session = { id, get events() { return observed } } as unknown as Session + } as unknown as Session expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) expect(reads).toBe(2) @@ -155,7 +156,7 @@ describe('incremental DeepSeek session-log upload', () => { const { ctx, session } = await harness('direct-events') session.append('turn/start', { turn: 1 }) const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL, sessionId: session.id }) - expect(prepared.fields.dsh_session_log?.events).toEqual(session.events) + expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) it('fails closed on a malformed persisted acceptance watermark', async () => { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index e244d3af51..e08e2c628c 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -592,13 +592,13 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { const loaded = await ctx.sessionPersistence.load(child.id) // The constructor seed reaches disk verbatim, then the child's end-seed. - expect(loaded.events.slice(0, source.events.length)).toEqual(source.events) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length }) + expect(loaded.events.slice(0, source.snapshotEvents().length)).toEqual(source.snapshotEvents()) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.snapshotEvents().length }) expect(loaded.meta).toMatchObject({ id: SessionId('persist-child'), cwd: '/workspace', parentSession: SessionId('persist-parent'), - seedLength: source.events.length, + seedLength: source.snapshotEvents().length, }) }) @@ -1647,13 +1647,13 @@ describe('JsonlSessionPersistence: edge cases', () => { it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { const session = ctx.sessions.create(SessionId('reject-bad')) // Serializability is enforced at the source: Session.append throws on a BigInt-bearing - // event before it enters session.events, so the durable log can never diverge from the live + // event before it enters session.snapshotEvents(), so the durable log can never diverge from the live // log. The error therefore surfaces synchronously at append, not later during backend flush. expect(() => { session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) }).toThrow(/non-JSON-serializable/) // The bad event was rejected, so the log stayed empty. - expect(session.events.length).toBe(0) + expect(session.snapshotEvents().length).toBe(0) }) }) diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 0293c01555..75e8bd9b72 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -762,7 +762,7 @@ export class PersistenceCoordinator { this.preparations.release( reservation, reservation.state.owner === undefined - && reservation.source.session.events.length === reservation.source.sessionLength, + && reservation.source.session.seq === reservation.source.sessionLength, ) }, }) @@ -996,7 +996,7 @@ export class PersistenceCoordinator { inspection, session, revision, - sessionLength: session.events.length, + sessionLength: session.seq, tornMarker, closers, } @@ -1053,7 +1053,7 @@ export class PersistenceCoordinator { /** Return one durable immutable view of an already-live Session. */ private async loadLiveSnapshot(session: Session): Promise { - const events = session.events + const events = session.snapshotEvents() await this.flush(session) const state = this.states.get(session.id) /* v8 ignore next -- successful flush always publishes this live session's durable state */ @@ -1067,7 +1067,7 @@ export class PersistenceCoordinator { /** Borrow one immutable view from an already-live Session. */ private inspectLive(session: Session): SessionInspection { - return Object.freeze({ meta: session.header, events: session.events }) + return Object.freeze({ meta: session.header, events: session.snapshotEvents() }) } /** Await one retiring lifecycle with caller cancellation. */ @@ -1248,7 +1248,7 @@ export class PersistenceCoordinator { return restored } // Session owns this stable deep-frozen snapshot; backends only serialize it. - const seed = session.events + const seed = session.snapshotEvents() const live: LiveSessionState = { init: Promise.resolve(), writes: this.createWriteBehind(session, () => live.init), @@ -1270,7 +1270,7 @@ export class PersistenceCoordinator { || session.firstLiveSeq !== state.cursor) { throw new Error(`session "${session.id}" preparation no longer matches its persistence state`) } - const suffix = session.events.slice(state.cursor).map(event => structuredClone(event)) + const suffix = session.snapshotEvents(state.cursor).map(event => structuredClone(event)) this.preparations.attach(reservation) state.owner = session const live: LiveSessionState = { diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index a596ed0b11..474ee4ca34 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -304,7 +304,7 @@ describe('PersistenceCoordinator seed ownership', () => { try { const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) - const seed = session.events + const seed = session.snapshotEvents() await ctx.sessions.flush(session) expect(backend.lastAppendedBatch).toBe(seed) @@ -764,7 +764,7 @@ describe('PersistenceCoordinator session preparations', () => { first = await coordinator.prepare(id) expect(backend.loadAttempts).toBe(1) - expect(first.session.events[0]).toBe(inspected.events[0]) + expect(first.session.snapshotEvents()[0]).toBe(inspected.events[0]) first[Symbol.dispose]() second = await coordinator.prepare(id) @@ -826,8 +826,8 @@ describe('PersistenceCoordinator session preparations', () => { ) preparation = await coordinator.prepare(id) - expect(preparation.session.events).toHaveLength(9) - expect(preparation.session.events[0]).not.toBe(inspected.events[0]) + expect(preparation.session.snapshotEvents()).toHaveLength(9) + expect(preparation.session.snapshotEvents()[0]).not.toBe(inspected.events[0]) expect(backend.loadAttempts).toBe(2) } finally { preparation?.[Symbol.dispose]() @@ -986,7 +986,7 @@ describe('PersistenceCoordinator session preparations', () => { session.append('turn/start', { turn: 1 }) const inspected = await coordinator.inspect(session.id) - expect(inspected.events).toBe(session.events) + expect(inspected.events).toBe(session.snapshotEvents()) expect(inspected.events.map(event => event.type)).toEqual(['turn/start']) await expect(coordinator.load(session.id)).rejects.toThrow(/live turn is open/) } finally { @@ -1068,7 +1068,7 @@ describe('PersistenceCoordinator session preparations', () => { try { preparation = await coordinator.prepare(id) - expect(preparation.session.events.map(event => event.type)).toEqual([ + expect(preparation.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'turn/end', 'turn/start', @@ -2102,7 +2102,7 @@ describe('SessionPersistence service registration', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } })) .toThrow(/unsupported legacy request\/header-delta format/) - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) await fiber.dispose() }) @@ -2115,7 +2115,7 @@ describe('SessionPersistence service registration', () => { expect(() => appendLegacy('request/header', legacyFallbackHeader().data)) .toThrow('unsupported legacy request/header reason "fallback"') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) await fiber.dispose() }) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 0a11f037b6..896c9fa632 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -584,10 +584,10 @@ export class SessionProjectionRegistry extends Service { private cellFor(registration: Registration, session: Session): UnitCell { let cell = registration.cells.get(session) if (cell === undefined) { - cell = this.buildCell(registration.def, session.header, session.events) + cell = this.buildCell(registration.def, session.header, session.snapshotEvents()) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session.events, session.seq - 1) + this.advanceCell(registration.def, cell, session, session.seq - 1) } return cell } @@ -596,12 +596,12 @@ export class SessionProjectionRegistry extends Service { private advanceCell( def: ErasedDefinition, cell: UnitCell, - events: readonly SessionEvent[], + session: Session, throughSeq: number, ): void { if (cell.observedSeq >= throughSeq) return for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { - const event = events[seq] + const event = session.eventAt(seq) if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) } @@ -619,10 +619,10 @@ export class SessionProjectionRegistry extends Service { if (cell === undefined) { // Late build mid-stream: fold history before this event (seq = log // index, so the prefix slice is exact), then take the normal gate. - cell = this.buildCell(registration.def, session.header, session.events.slice(0, event.seq)) + cell = this.buildCell(registration.def, session.header, session.snapshotEvents(0, event.seq)) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session.events, event.seq - 1) + this.advanceCell(registration.def, cell, session, event.seq - 1) } const next = registration.def.apply(cell.state, event) const changed = !Object.is(next, cell.state) diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index f5601caa0f..4c5dc734e0 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/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/session/session-telemetry-otel/README.md -README.md: 8aa215f5fd2e1344911e3f069f038a4f89f6fa3f -README.zh.md: 1bb3aa8f3b3e4b2f50e66e1833a8f4ab00112a8b +README.md: f270878962bdc365915e76a67ffc2490db14d33e +README.zh.md: d8fcef0610b22abddb3f051cb6056d89f3fc65b5 diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 8aa215f5fd..f270878962 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -93,7 +93,7 @@ The backend is a thin adapter over the OTel JS SDK: it owns capture mode, resour ### Capture wiring -`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only for the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented. +`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only when `session.eventAt(event.seq) === event` confirms the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented. ### Field mapping diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 1bb3aa8f3b..d8fcef0610 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -93,7 +93,7 @@ kind: "package-reference" ### 捕获接线 -`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且只对权威日志中精确的反馈记录触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档。 +`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且仅在 `session.eventAt(event.seq) === event` 确认精确的权威反馈记录时触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档。 ### 字段映射 diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 169f4dc9de..c88d442c87 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -244,7 +244,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { ctx.on('session/event', (session, event) => { if (event.type !== 'feedback/record') return // Consent is the committed record, not an independently emitted bus value. - if (session.events[event.seq] !== event) { + if (session.eventAt(event.seq) !== event) { ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING) return } diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index afb96c8f60..822203abb8 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -150,7 +150,7 @@ describe('OpenTelemetrySessionBackend wire', () => { const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) expect(start).toBeDefined() expect(start?.record.severityNumber).toBe(9) - expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n) + expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents()[0]!.time) * 1_000_000n) expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } }) const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) @@ -325,7 +325,7 @@ describe('OpenTelemetrySessionBackend wire', () => { }) ctx.emit('session/event', session, { type: 'feedback/record', - seq: session.events.length, + seq: session.snapshotEvents().length, time: Date.now(), data: { text: 'not committed' }, }) diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index ff0417f168..8725888d7b 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -137,9 +137,14 @@ export class SessionTelemetryCoordinator { */ captureSession(session: Session, throughSeq?: number): void { const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 + const length = session.seq // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. - for (const event of session.events) { + for (let seq = 0; seq < length; seq++) { + const event = session.eventAt(seq) + if (event === undefined) { + throw new Error(`session telemetry capture cannot read seq ${String(seq)} below captured length ${String(length)}`) + } if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { if (event.seq <= cursor) this.track(session, event) diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index 219c27ebcf..b563d9c469 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -72,7 +72,7 @@ describe('session-telemetry/record waterfall', () => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const logged = session.events[0]!.data as { content: { text: string }[] } + const logged = session.snapshotEvents()[0]!.data as { content: { text: string }[] } expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) }) @@ -124,6 +124,6 @@ describe('session-telemetry/record waterfall', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) expect(backend.records).toHaveLength(0) - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) }) diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index a1d80a1289..403451a49a 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -96,12 +96,12 @@ describe('SessionTelemetryCoordinator capture', () => { const start = backend.ledger()[0]! const message = backend.ledger()[1]! expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 }) - expect(start.time).toBe(session.events[0]!.time) + expect(start.time).toBe(session.snapshotEvents()[0]!.time) expect(start.severity).toBe('info') expect(message.attributes['event.seq']).toBe(1) // Deep-copy isolation: mutating the handed-off body never reaches the log. ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered' - const logged = session.events[1] as SessionEvent<'user/message'> + const logged = session.snapshotEvents()[1] as SessionEvent<'user/message'> expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) }) @@ -183,7 +183,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) - const firstBoundary = session.events[1]!.seq + const firstBoundary = session.snapshotEvents()[1]!.seq session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.records).toEqual([]) @@ -288,7 +288,7 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) - const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.snapshotEvents()], meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) ctx.sessions.announce(child) @@ -307,7 +307,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) - const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.snapshotEvents()], meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], @@ -335,7 +335,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { - seed: [...parent.events], + seed: [...parent.snapshotEvents()], meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, }) await ctx.plugin({ diff --git a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts index 8c358bfc35..a16b415b72 100644 --- a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts @@ -53,7 +53,7 @@ describe('all-messages LLM title provider', () => { ctx.llm.registerAdapter(['current-route'], adapter) await ctx.plugin(providerPlugin, LLM_CONFIG) const session = ctx.sessions.create(SessionId('all-plugin'), { - seed: seeded.events, + seed: seeded.snapshotEvents(), meta: { parentSession: seeded.id, seedLength: seeded.seq }, }) session.append('turn/start', { turn: 2 }) diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index f635c921e8..240ddefa2b 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -126,7 +126,7 @@ describe('generateSessionTitleWithLlm', () => { const providerRequest = request(ctx) let requestWasLoggedAtDispatch = false const adapter = new RecordingAdapter(SCRIPT, () => { - requestWasLoggedAtDispatch = providerRequest.session.events + requestWasLoggedAtDispatch = providerRequest.session.snapshotEvents() .some(event => event.type === 'session/title-llm-request') }) ctx.llm.registerAdapter(['current-route'], adapter) @@ -162,7 +162,7 @@ describe('generateSessionTitleWithLlm', () => { const prompt = options.messages[0]?.content[0] expect(prompt?.type === 'text' && prompt.text).toContain('first prompt') expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题') - expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data) + expect(providerRequest.session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data) .toEqual({ titleProvider: TITLE_PROVIDER, messageSeqs: providerRequest.messages.map(message => message.seq), @@ -193,7 +193,7 @@ describe('generateSessionTitleWithLlm', () => { await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER)) .rejects.toThrow(/input.*bytes.*maxInputBytes/i) expect(adapter.requests).toEqual([]) - expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false) + expect(oversized.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false) const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 }) const within = request(ctx) @@ -261,7 +261,7 @@ describe('generateSessionTitleWithLlm', () => { providerRequest.messages, TITLE_PROVIDER, )).rejects.toMatchObject({ message, code }) - expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true) + expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(true) }) it.each([ diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 5b6df7e414..e220cfdf3a 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -381,7 +381,7 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { - return foldSessionTitle(session.events) + return foldSessionTitle(session.snapshotEvents()) } /** @@ -591,7 +591,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = collectSessionTitleMessages(session.events, work.throughSeq) + const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq) const result = await work.registration.provider.generate({ session, messages, diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 53fc323d38..f96da740ff 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -65,8 +65,8 @@ describe('SessionTitleService Provider lifecycle', () => { const child = ctx.sessions.fork(parent, undefined, SessionId('title-child')) expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent)) - expect(child.events.find(event => event.type === 'session/title')) - .toEqual(parent.events.find(event => event.type === 'session/title')) + expect(child.snapshotEvents().find(event => event.type === 'session/title')) + .toEqual(parent.snapshotEvents().find(event => event.type === 'session/title')) const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Should not run', @@ -376,7 +376,7 @@ describe('SessionTitleService Provider lifecycle', () => { }))) await settle() - expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(requests).toHaveLength(2) expect(requests[1]).toMatchObject({ messages: [ diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 7701d953b7..9654c18edc 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -46,14 +46,14 @@ describe('SessionTitleService.rename', () => { messageSeqs: [], source: { kind: 'user' }, }) - const event = session.events.findLast(item => item.type === 'session/title') + const event = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.data).toEqual({ title: 'Hand picked name', messageSeqs: [], source: { kind: 'user' }, }) // foldSessionTitle round-trips the third source kind. - expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' }) + expect(foldSessionTitle(session.snapshotEvents())?.source).toEqual({ kind: 'user' }) }) it('rejects titles that normalize to empty and dead sessions', async () => { @@ -164,7 +164,7 @@ describe('SessionTitleService.rename', () => { await settle() // The released provider result must not append over the user title, and // the swallowed abort must not surface as an unhandled rejection. - const latest = session.events.findLast(item => item.type === 'session/title') + const latest = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } }) }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 6ac579bd35..ce3e4997bb 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -171,7 +171,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events }) + const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.snapshotEvents() }) const results = await Promise.all([ ctx.sessionTitle.refresh(session), @@ -179,8 +179,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => { ]) expect(results[0]).toEqual(results[1]) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', @@ -204,7 +204,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) it('lets the newest overlapping explicit refresh win', async () => { @@ -266,7 +266,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await fiber.dispose() await settle() - expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) const inactiveError = await lifecycle.inactiveRefresh expect(inactiveError).toBeInstanceOf(Error) if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject') @@ -285,7 +285,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await Promise.resolve() await fiber.dispose() - expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) expect(warn).not.toHaveBeenCalled() }) diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index b96eb6001b..0254ff5826 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -53,7 +53,7 @@ describe('SessionTitleService', () => { await settleTitles() - const titleEvent = session.events.findLast(event => event.type === 'session/title') + const titleEvent = session.snapshotEvents().findLast(event => event.type === 'session/title') expect(titleEvent).toMatchObject({ type: 'session/title', seq: 2, @@ -140,7 +140,7 @@ describe('SessionTitleService', () => { expect(first?.messageSeqs).toEqual([eligible.seq]) expect(ctx.sessionTitle.get(session)).toEqual(first) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) it('folds the latest title event during replay', () => { @@ -160,7 +160,7 @@ describe('SessionTitleService', () => { }, }) - expect(foldSessionTitle(seed.events)).toEqual({ + expect(foldSessionTitle(seed.snapshotEvents())).toEqual({ title: 'Later', messageSeqs: [1, 4], source: { @@ -169,13 +169,13 @@ describe('SessionTitleService', () => { model: { provider: 'mock', model: 'title-model' }, }, eventSeq: 1, - updatedAt: seed.events[1]?.time, + updatedAt: seed.snapshotEvents()[1]?.time, }) }) it('folds an empty or title-less log to undefined', () => { expect(foldSessionTitle([])).toBeUndefined() const empty = Session.create(SessionId('no-title')) - expect(foldSessionTitle(empty.events)).toBeUndefined() + expect(foldSessionTitle(empty.snapshotEvents())).toBeUndefined() }) }) diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index c645230ac2..ac54cc7c06 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -61,7 +61,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } /** Find a session event by type, narrowed; throws when absent. */ diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index d4c0a17203..01200022e0 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -214,7 +214,7 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, - events, + snapshotEvents: () => events, append: (type: string, data: Record) => { const event = { type, data } events.push(event) @@ -625,7 +625,7 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string } }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index e1d15a1bae..67ac413252 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -247,7 +247,7 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, - events, + snapshotEvents: () => events, append: (type: string, data: Record) => { const event = { type, data } events.push(event) @@ -270,7 +270,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, + session: { id, header: { version: 0, id, createdAt: 0 }, snapshotEvents: () => [] }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -602,7 +602,7 @@ describe('sandbox escalation through ctx.approval', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string } }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, }) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 222e8a0ace..c620afd9c5 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -338,7 +338,7 @@ function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { * Entries of one durable catalog message, or undefined when the record is not a * usable catalog. * - * `agent.session.events` may be a resumed, forked, or externally written seed, + * `agent.session.snapshotEvents()` may contain a resumed, forked, or externally written seed, * and seed validation only guarantees a source object with a non-empty `kind`; * no per-kind field is checked there. An unreadable record is therefore treated * as "not this plugin's catalog" — the posture the replaced content digest had — @@ -360,12 +360,12 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const events = agent.session.events let published = false - for (let index = events.length - 1; index >= 0; index -= 1) { - // The loop bounds prove the read-only event view contains this index. - // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! + for (let index = agent.session.seq - 1; index >= 0; index -= 1) { + const event = agent.session.eventAt(index) + if (event === undefined) { + throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`) + } if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue const entries = readCatalogEntries(event.data.source) if (entries === undefined) continue diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 134fc85a62..c19e5be4e6 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -110,7 +110,7 @@ async function proposeStep( } function catalogMessages(session: Session): Extract[] { - return session.events.filter((event): event is Extract => event.type === 'user/message' + return session.snapshotEvents().filter((event): event is Extract => event.type === 'user/message' && event.data.source.kind === 'skill-catalog') } @@ -544,7 +544,7 @@ describe('dsh-tool-skill', () => { }) it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { - // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork, + // Seeds reach `agent.session.snapshotEvents()` from JSONL/SQLite on resume or fork, // and seed validation only guarantees a source object with a non-empty // `kind`. A catalog whose entries are missing or wrongly shaped must be // skipped like any foreign record; throwing here would fail every later diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 9786f585fa..51dd2f44ba 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -46,7 +46,7 @@ export const Config: z = z.object({ * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ function completedTurnPrefix(parent: Agent): SessionEvent[] { - const events = parent.session.events + const events = parent.session.snapshotEvents() const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] // seq === array index (the append contract), so slice up to and including it. diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index f14c5d001b..67b0dd9d70 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -69,7 +69,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { // Parent does one real turn first, so the fork has a completed turn to seed. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q1' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length // Delegate to a fresh spawn child. const spawnRun = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent }) @@ -90,7 +90,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) // The fork child inherited the parent's prefix; the spawn child did not. - expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) + expect(forkChild.session.snapshotEvents().slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) await spawnRun.dispose() await forkRun.dispose() @@ -98,12 +98,12 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { // The parent is unaffected and keeps working after both delegations. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q2' }], source: { kind: 'user' } })) await parent.whenIdle() - const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') + const lastParentMessage = parent.session.snapshotEvents().findLast(e => e.type === 'assistant/message') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.message.content)).toBe('parent turn two') // The parent's OWN log never recorded the children's internal steps — its // only subagent-related entries would be tool/call+tool/result IF it had // used the tool, but here we called the service directly, so the parent log // is purely its own two turns. - expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(parent.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(2) }) }) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 1d4a98e60e..ca2618dd7a 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -85,7 +85,7 @@ describe('dsh-subagent-fork-in-process', () => { expect(text(result.output)).toBe('fresh child') const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. - expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) expect(child.session.header.seedLength).toBeUndefined() await run.dispose() }) @@ -96,14 +96,14 @@ describe('dsh-subagent-fork-in-process', () => { await parent.whenIdle() parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) await run.result const child = ctx.agents.get(run.id)! expect(child.session.header.seedLength).toBe(parentPrefixLen) - expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') - expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(child.session.snapshotEvents().slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.snapshotEvents().slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) @@ -111,7 +111,7 @@ describe('dsh-subagent-fork-in-process', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) const result = await run.result @@ -120,9 +120,9 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! // The child's log STARTS with the parent's prefix (seeded), then its own turn. - expect(child.session.events.length).toBeGreaterThan(parentPrefixLen) + expect(child.session.snapshotEvents().length).toBeGreaterThan(parentPrefixLen) // The seeded prefix carried the parent's user message. - const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message') + const seededUser = child.session.snapshotEvents().slice(0, parentPrefixLen).find(e => e.type === 'user/message') expect(seededUser).toBeDefined() // Lineage stamped. expect(child.session.header.parentSession).toBe(parent.session.header.id) @@ -152,7 +152,7 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! // The child's seed has exactly the ONE completed parent turn (the open one excluded). - const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end') + const seedTurnEnds = child.session.snapshotEvents().filter(e => e.type === 'turn/end') // 1 from the seeded parent turn + 1 from the child's own completed turn. expect(seedTurnEnds.length).toBe(2) diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index fd1ebff2be..7baa1069fd 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -211,7 +211,7 @@ function readResult( cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { - const own = child.session.events.slice(boundary) + const own = child.session.snapshotEvents(boundary) // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its // awaited first turn almost immediately, and the owner's own teardown is the // `cancelled` flag below. A cancellation with no accounting turn resolves diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index 4ff72cba21..a30107342e 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -71,7 +71,7 @@ function spawnRequest(parent: Agent) { } function toolResultTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') .map(event => event.data.message.content .flatMap(block => block.content) @@ -88,7 +88,7 @@ describe('in-process policy inheritance', () => { setSandboxMode(parent.session, 'read-only') // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() - const parentLogLength = parent.session.events.length + const parentLogLength = parent.session.snapshotEvents().length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), @@ -102,7 +102,7 @@ describe('in-process policy inheritance', () => { await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) expect(result.stopReason).toBe('completed') - expect(child.session.events.slice(0, 2)).toMatchObject([ + expect(child.session.snapshotEvents().slice(0, 2)).toMatchObject([ { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } }, { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, ]) @@ -110,10 +110,10 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') expect(ctx.approval.overrideOf(child.session)).toBe('never') - const request = child.session.events.find( + const request = child.session.snapshotEvents().find( (event): event is SessionEvent<'request/header'> => event.type === 'request/header', ) - const runtimeContext = child.session.events.find( + const runtimeContext = child.session.snapshotEvents().find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt', @@ -130,7 +130,7 @@ describe('in-process policy inheritance', () => { expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') expect(request.data.header.system).not.toContain('You are a delegated subagent') - expect(parent.session.events).toHaveLength(parentLogLength) + expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength) } finally { await run.dispose() } @@ -141,7 +141,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') - const seed = [...parent.session.events] + const seed = [...parent.session.snapshotEvents()] setSandboxMode(parent.session, 'read-only') script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -156,7 +156,7 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) // seq 1 is the constructor's end-seed marker. - expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + expect(child.session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toMatchObject([ { seq: 0, data: { mode: 'workspace-write' } }, { seq: 2, data: { mode: 'read-only', source: 'delegation' } }, ]) @@ -202,8 +202,8 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false) - expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false) + expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([ { seq: 0, data: { policy: 'never', source: 'delegation' } }, ]) expect(child.session.firstLiveSeq).toBe(0) @@ -242,10 +242,10 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') - const asked = child.session.events.find( + const asked = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) - const decided = child.session.events.find( + const decided = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', ) expect(asked?.data.toolName).toBe('write') diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index e912c2516f..d34729037c 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -87,7 +87,7 @@ describe('a child agent composed in-process', () => { const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - expect(run.localAgent?.session.events.some(event => + expect(run.localAgent?.session.snapshotEvents().some(event => event.type === 'request/header' && JSON.stringify(event.data).includes('section for preset_only'))).toBe(true) await run.dispose() diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 731ada1fd9..41f86d099f 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -191,7 +191,7 @@ describe('in-process structured output', () => { expect(result.structured).toEqual({ answer: 5 }) expect(sideEffectRan).toBe(false) const child = ctx.agents.get(run.id) - const sideEffectResult = child?.session.events.find(event => + const sideEffectResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c2') expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.content[0].isError).toBe(true) await run.dispose() @@ -235,7 +235,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('completed') // The child's log carries the isError tool/result for the invalid call. const child = ctx.agents.get(run.id)! - const results = child.session.events.filter(e => e.type === 'tool/result') + const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results.length).toBe(2) expect(results[0]!.data.message.content[0].isError).toBe(true) await run.dispose() @@ -253,7 +253,7 @@ describe('in-process structured output', () => { // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! - expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) + expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) @@ -318,7 +318,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') // ...the logged tool result is the blocked isError with the feedback... const child = ctx.agents.get(run.id)! - const results = child.session.events.filter(e => e.type === 'tool/result') + const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results[0]!.data.message.content[0].isError).toBe(true) expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook') // ...and the turn CONTINUED past the blocked call (no captured veto): @@ -363,7 +363,7 @@ describe('in-process structured output', () => { expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') const child = ctx.agents.get(run.id) - const captureResult = child?.session.events.find(event => + const captureResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c1') expect(captureResult?.type === 'tool/result' && captureResult.data.message.content[0].isError).toBe(true) await run.dispose() @@ -434,7 +434,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') expect(adapter.requests).toHaveLength(2) const child = ctx.agents.get(run.id)! - const outer = child.session.events.find(event => + const outer = child.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === ToolCallId('c1')) expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true) await run.dispose() diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index b69a549983..a4e4cb61f6 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -152,7 +152,7 @@ describe('startInProcessRun', () => { let injected = false ctx.on('session/flush', (session) => { if (injected || session.header.parentSession === undefined) return - const lastEnd = session.events.findLast(event => event.type === 'turn/end') + const lastEnd = session.snapshotEvents().findLast(event => event.type === 'turn/end') if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return injected = true session.append('user/message', createUserMessage({ @@ -166,7 +166,7 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(injected).toBe(false) - expect(child.session.events.findLast(event => event.type === 'turn/end')) + expect(child.session.snapshotEvents().findLast(event => event.type === 'turn/end')) .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) expect(result.stopReason).toBe('max-tokens') await run.dispose() @@ -201,13 +201,13 @@ describe('startInProcessRun', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const seed = parent.session.events.slice() + const seed = parent.session.snapshotEvents().slice() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result expect(text(result.output)).toBe('child answer') const child = ctx.agents.get(run.id)! expect(child.session.header.seedLength).toBe(seed.length) - expect(child.session.events.slice(0, seed.length)).toEqual(seed) + expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) await run.dispose() }) @@ -328,7 +328,7 @@ describe('startInProcessRun', () => { }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) - const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = child?.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } }) await signalled.dispose() diff --git a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index ba76c69203..ee2c79e1e1 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( // The parent's log records the subagent tool/call + its result (not the // child's internal steps). - const events = [...parent.session.events] + const events = [...parent.session.snapshotEvents()] const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) }, 180_000) diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index b3efa062e7..c46f99514f 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -111,14 +111,14 @@ describe('dsh-subagent-spawn-in-process', () => { const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent prompt' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentEventCount = parent.session.events.length + const parentEventCount = parent.session.snapshotEvents().length expect(parentEventCount).toBeGreaterThan(0) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) await run.result const child = ctx.agents.get(run.id)! // The child's first user/message is its OWN prompt, not the parent's history. - const firstUser = child.session.events.find(e => e.type === 'user/message') + const firstUser = child.session.snapshotEvents().find(e => e.type === 'user/message') expect(firstUser).toBeDefined() await run.dispose() }) @@ -434,7 +434,7 @@ describe('dsh-subagent-spawn-in-process', () => { expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool') // …and the attempted call executed as UNKNOWN_TOOL (visible in the log). const child = ctx.agents.get(run.id)! - const toolResult = child.session.events.find(e => e.type === 'tool/result')! + const toolResult = child.session.snapshotEvents().find(e => e.type === 'tool/result')! expect(JSON.stringify(toolResult.data)).toContain('unknown tool') await run.dispose() }) diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index a6b5dcf3e1..1b5c90bb07 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -27,5 +27,5 @@ export function seedDescriptorTurn( ): SessionEvent[] { const staged = Session.create(childId, seed) staged.append('subagent/descriptor', descriptor) - return [...staged.events] + return [...staged.snapshotEvents()] } diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 2e1d513d5a..2f4e6d5392 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -193,11 +193,11 @@ export function createActivationObserver( : { stopReason: 'error' } return { start: (child: Agent): void => { - boundary = child.session.events.length + boundary = child.session.seq emit('subagent/start', identity, parent) }, capture: (child: Agent): void => { - const own = child.session.events.slice(boundary) + const own = child.session.snapshotEvents(boundary) const output = finalAssistantOutput(own) captured = { stopReason: epochStopReason(own), diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 31b5bdbcb5..17a61c88b2 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -195,7 +195,7 @@ describe('SubagentRuntime.startContinuable', () => { ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.snapshotEvents(), 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -405,7 +405,7 @@ describe('SubagentRuntime.startContinuable', () => { expect(found).toBeDefined() return found! }) - const descriptor = child.session.events.find(event => event.type === 'subagent/descriptor') + const descriptor = child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor') expect(descriptor?.data).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, @@ -440,7 +440,7 @@ describe('SubagentRuntime.startContinuable', () => { return found! }) - expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data) + expect(child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor')?.data) .toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', @@ -1362,7 +1362,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - const before = child.session.events.length + const before = child.session.snapshotEvents().length const controller = new AbortController() controller.abort('caller gave up') @@ -1695,7 +1695,7 @@ describe('continuable review regressions', () => { /** Every settlement notice this agent received, in order, as flat text. */ function settlementNotices(agent: Agent): { sender: string; text: string; summary: string }[] { - const logged = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + const logged = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...logged, ...agent.inbox.nextStep, ...agent.inbox.nextTurn].flatMap((message) => { if (message.source.kind !== 'subagent-settled') return [] return [{ @@ -1730,7 +1730,7 @@ describe('continuable report delivery', () => { await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) }) - const report = parent.session.events.flatMap(event => event.type === 'user/message' + const report = parent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'subagent-report' ? [event.data] : [])[0] expect(report?.id).toBe(messageId) @@ -1967,7 +1967,7 @@ describe('continuable settlement delivery', () => { // Turn 1 closed cleanly and no later turn opened, so the cancelled queue is // the only record that this epoch was cut short. - expect(hasUserText(child.session.events, 'never runs')).toBe(false) + expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( `Background subagent ${started.childId} was stopped before it finished.` @@ -2144,8 +2144,8 @@ describe('continuable settlement delivery', () => { `Background subagent ${started.childId} was stopped before it finished.` + '\nIt left no closing message.', ) - expect(parent.session.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true) - expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) + expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(parent.status).toBe('idle') }) @@ -2161,7 +2161,7 @@ describe('continuable settlement delivery', () => { await drained expect(settlementNotices(parent)).toHaveLength(1) - expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) it('records but cannot deliver a teardown notice once the parent is disposed too', async () => { diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 30a5568d94..91ca89bd6d 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -140,7 +140,7 @@ function registerReportConflict(child: Agent): () => void { /** Reports already visible or still pending in one Agent. */ function reports(agent: Agent): { id: string; text: string; sender: string }[] { - const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + const visible = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...visible, ...agent.inbox.nextStep].flatMap((message) => { if (message.source.kind !== 'subagent-report') return [] return [{ diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 5282ee0a63..1aa93df37c 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -306,7 +306,7 @@ describe('SubagentModelSelectionConfig', () => { const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) - const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) + const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.snapshotEvents() }) expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) @@ -314,7 +314,7 @@ describe('SubagentModelSelectionConfig', () => { enabled: true, allowedModels: ALLOWED_MODELS, }) - const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 148f9e37bd..4799709b3f 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -615,7 +615,7 @@ describe('terminal-bash plugin shape', () => { expect(() => { setSandboxMode(session, 'read-only') }).toThrow( 'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first', ) - expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(1) const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const second = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -625,7 +625,7 @@ describe('terminal-bash plugin shape', () => { await ctx.terminals.kill(owner, created.sessionId) await ctx.terminals.kill(owner, second.sessionId) expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() - expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2) + expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(2) }) it('also fences sandbox-mode changes across unpublished PTY creation', async () => { diff --git a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts index 916b61aa0d..6830cbaf4b 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts @@ -19,7 +19,7 @@ export function apply(ctx: Context): void { || exec.arguments.file_path !== 'nested/task.txt') return downstream const agent = exec.agent const baseline = agent.session.surface.nodes - .map(seq => agent.session.events[seq]) + .map(seq => agent.session.snapshotEvents()[seq]) .find(event => event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index fee7088a40..21970cbcb9 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -60,7 +60,7 @@ function validateEvent(event: SessionEvent, trace: TurnTrace, fail: InvariantFai /** Validate one existing log in a single pass and return its tail trace. */ function seedTrace(session: Session, fail: InvariantFailure): TurnTrace { const trace: TurnTrace = { open: false } - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateEvent(event, trace, fail) advanceTrace(trace, event) } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 0692772ea9..2ca91bb10f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -65,7 +65,7 @@ describe('todo_write tool through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan a two-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() expect(findEvent(log, 'tool/call').data.name).toBe('todo_write') expect(findEvent(log, 'tool/result').data.message.content[0].isError).toBe(false) @@ -93,9 +93,9 @@ describe('todo_write tool through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan then update' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') + const todoEvents = agent.session.snapshotEvents().filter(e => e.type === 'todo/write') expect(todoEvents).toHaveLength(2) - expect(findEvent(agent.session.events, 'todo/write', 'last').data.todos).toEqual([ + expect(findEvent(agent.session.snapshotEvents(), 'todo/write', 'last').data.todos).toEqual([ { content: 'step one', status: 'completed' }, { content: 'step two', status: 'in_progress' }, ]) diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 7d53ad052b..e3d2001554 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,10 +66,10 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const before = [...session.events] + const before = [...session.snapshotEvents()] expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) }) it('rejects an existing snapshot outside an open turn on late registration', async () => { diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 565269f1e8..d25d02bba9 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -111,7 +111,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { }) expect(result.isError).toBe(true) expect(resultText(result)).toContain('at most one task may be in_progress') - expect(owner.session.events.some(e => e.type === 'todo/write')).toBe(false) + expect(owner.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }, 30_000) it('allowParallelInProgress: true permits a parallel write end to end', async () => { @@ -128,7 +128,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { agent: owner, }) expect(result.isError).toBe(false) - expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) + expect(owner.session.snapshotEvents().findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) it.each([ diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 713ef08e5c..c54f710c33 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -82,7 +82,7 @@ describe('dsh-tool-todo', () => { }) expect(text(result)).toContain('1 pending, 1 in progress, 0 completed') - const event = agent.session.events.findLast(e => e.type === 'todo/write')! + const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual(todos) }) @@ -92,7 +92,7 @@ describe('dsh-tool-todo', () => { const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent }) expect(result.isError).toBe(false) - const event = agent.session.events.findLast(e => e.type === 'todo/write')! + const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual([{ content: 'plan the work', status: 'pending' }]) }) @@ -105,7 +105,7 @@ describe('dsh-tool-todo', () => { { content: 'b', status: 'in_progress' }, ] }, { agent }) - const current = agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos + const current = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'a', status: 'completed' }, { content: 'b', status: 'in_progress' }, @@ -139,7 +139,7 @@ describe('dsh-tool-todo', () => { todos, counts: { pending: 1, inProgress: 2, completed: 0 }, }) - expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) + expect(agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) describe('allowParallelInProgress', () => { @@ -155,7 +155,7 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('at most one task may be in_progress') // A rejected call must not reach the durable log. - expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }) it('false still accepts one active item', async () => { @@ -247,7 +247,7 @@ describe('todo/write event', () => { ] session.append('todo/write', { todos }) - const event = session.events.findLast(e => e.type === 'todo/write')! + const event = session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.type).toBe('todo/write') expect(event.data.todos).toEqual(todos) @@ -268,7 +268,7 @@ describe('todo/write event', () => { { content: 'second', status: 'in_progress' }, ] }) - const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos + const current = session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'first', status: 'completed' }, { content: 'second', status: 'in_progress' }, @@ -293,11 +293,11 @@ describe('todo/write event', () => { original.append('turn/start', { turn: 1 }) original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('t4-replay'), [...original.events]) + const replayed = Session.create(SessionId('t4-replay'), original.snapshotEvents()) - expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos) + expect(replayed.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.firstLiveSeq).toBe(original.seq) }) }) diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 4b32a03f66..d36174b94f 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -135,7 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) + for (const event of session.snapshotEvents().filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 06e8b0511b..e3404d6c12 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -146,7 +146,7 @@ describe('dsh-tool-workflow', () => { engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 }) expect((await pending).isError).toBe(false) expect(engine.disposed).toBe(1) - expect(session.events.map(event => [event.type, event.data])).toEqual([ + expect(session.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], ['tool-workflow/agent-start', { runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1', @@ -166,10 +166,10 @@ describe('dsh-tool-workflow', () => { value: null, stopReason: 'completed', agentsStarted: 0, }) await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) - expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start']) + expect(session.snapshotEvents().map(event => event.type)).toEqual(['tool-workflow/run-start']) barrier.resolve(undefined) expect((await pending).isError).toBe(false) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'tool-workflow/run-start', 'tool-workflow/run-end', ]) }) @@ -190,9 +190,9 @@ describe('dsh-tool-workflow', () => { engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 }) expect((await first).isError).toBe(false) expect((await second).isError).toBe(true) - expect(session.events.filter(event => event.type === 'tool-workflow/agent-start')) + expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/agent-start')) .toHaveLength(1) - expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) + expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) .toEqual([ { runId: 'run-1', stopReason: 'completed' }, { runId: 'run-2', stopReason: 'error' }, @@ -208,7 +208,7 @@ describe('dsh-tool-workflow', () => { await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) expect((await pending).isError).toBe(false) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it.each([ @@ -240,7 +240,7 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain(failedType) - const types = session.events.map(event => event.type) + const types = session.snapshotEvents().map(event => event.type) const expectedPrefixes = { 'tool-workflow/run-start': [], 'tool-workflow/agent-start': ['tool-workflow/run-start'], From bcfec8d1c3cc53bbfbf6b11a70babe0dbc44db94 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 13:43:16 +0800 Subject: [PATCH 033/429] perf(session): reuse immutable event snapshots --- .../headless/tests/coding-task.e2e.ts | 2 +- .../profiles/headless/tests/compaction.e2e.ts | 2 +- .../profiles/headless/tests/full-loop.e2e.ts | 2 +- .../tests/profiles/headless/tests/harness.ts | 2 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 4 +- .../profiles/headless/tests/resume.e2e.ts | 2 +- .../profiles/headless/tests/todo-write.e2e.ts | 2 +- .../api/session-controller/src/commands.ts | 4 +- packages/api/session-controller/src/index.ts | 4 +- .../tests/compaction-basic.spec.ts | 2 +- .../tests/compaction-loop-repro.spec.ts | 10 ++--- .../tests/manual-compaction.spec.ts | 6 +-- .../tests/tool-result-pruner.spec.ts | 2 +- .../context/agent-instructions/src/state.ts | 4 +- .../tests/agent-instructions.e2e.ts | 8 ++-- .../tests/agent-instructions.spec.ts | 40 +++++++++--------- .../time-context/tests/time-context.spec.ts | 2 +- .../tests/contract-regressions.spec.ts | 42 +++++++++---------- .../agent-loop/tests/interception.spec.ts | 4 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- .../agent-loop/tests/request-cache.e2e.ts | 2 +- .../tests/request-reconstruction.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 2 +- .../core/agent-loop/tests/tool-calls.spec.ts | 4 +- .../core/session/tests/derived-cache.spec.ts | 2 +- .../core/session/tests/properties.spec.ts | 6 +-- packages/core/session/tests/session.spec.ts | 4 +- packages/core/session/tests/surface.spec.ts | 2 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- .../tests/repeat-tool-reminder.spec.ts | 6 +-- .../hooks/hook-protocol/tests/events.spec.ts | 18 ++++---- .../hooks-claude-code/tests/bridge.spec.ts | 4 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 2 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 6 +-- .../shell/tool-bash/tests/integration.spec.ts | 6 +-- .../subagent-in-process-driver/src/index.ts | 2 +- .../tests/inheritance.spec.ts | 2 +- .../tests/subagent-in-process-driver.spec.ts | 2 +- .../tests/spawn-in-process.e2e.ts | 2 +- .../subagent/subagent/src/descriptor-seed.ts | 4 +- .../todo/tool-todo/tests/invariant.spec.ts | 2 +- 43 files changed, 116 insertions(+), 116 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts index 7f8e09c6c5..f6155102e3 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -67,7 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… - const summary = finalText([...agent.session.snapshotEvents()]).toLowerCase() + const summary = finalText(agent.session.snapshotEvents()).toLowerCase() expect(summary.length).toBeGreaterThan(0) // …and the world agrees: the test passes when WE run it, and the test diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 52889ce645..7b22be2958 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() // A compaction ran: the start…end bracket landed in the real log. const starts = events.filter(e => e.type === 'compaction/start') diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts index 22ca1e1976..cf55e35d28 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -34,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) expect(calls.some(event => event.data.name === 'bash')).toBe(true) diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index 495b7ebbd2..e09be01d0e 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -96,7 +96,7 @@ export function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -export function finalText(events: SessionEvent[]): string { +export function finalText(events: readonly SessionEvent[]): string { const message = events.findLast(event => event.type === 'assistant/message') if (message?.type !== 'assistant/message') return '' return message.data.message.content diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 2b0306fe7f..fba29b93b3 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,7 +366,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr + 'and return only the joined string.', }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events: SessionEvent[] = [...agent.session.snapshotEvents()] + const events: readonly SessionEvent[] = agent.session.snapshotEvents() // The wire contract: every request this session made offered EXACTLY ONE // tool — run_code (the logged header snapshots the assembled list). @@ -418,7 +418,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - const events: SessionEvent[] = [...handle.agent.session.snapshotEvents()] + const events: readonly SessionEvent[] = handle.agent.session.snapshotEvents() const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index fc4e815d2e..a47c01c4b6 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,6 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. - expect(finalText([...resumed.session.snapshotEvents()])).toContain(SECRET) + expect(finalText(resumed.session.snapshotEvents())).toContain(SECRET) }, 180_000) }) diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts index eaff804410..5fa792b672 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -37,7 +37,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a + 'Send all three in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() // The model actually called the tool. const calls = events.filter(event => event.type === 'tool/call') diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index efbf02d60f..1e4f236928 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -48,7 +48,7 @@ import type { interface SessionReadState { readonly id: SessionId readonly header: SessionHeader - readonly events: SessionEvent[] + readonly events: readonly SessionEvent[] } /** Implements Session business commands delegated by the Session Controller Remote service. */ @@ -484,7 +484,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return { id: attached.id, header: attached.header, events: [...attached.snapshotEvents()] } + return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } } const inspected = await inspectApiSession(this.ctx, sessionId) return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b18d013711..01df3942ee 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -191,10 +191,10 @@ export class SessionController extends TypertRemoteService { inspect( sessionId: SessionId, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return Promise.resolve({ meta: attached.header, events: [...attached.snapshotEvents()] }) + return Promise.resolve({ meta: attached.header, events: attached.snapshotEvents() }) } return inspectApiSession(this.ctx, sessionId, signal) } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 89e62c0db5..20125c18c6 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -882,7 +882,7 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - const replay = Session.create(SessionId('replay'), [...session.snapshotEvents()]) + const replay = Session.create(SessionId('replay'), session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 7d44f6e268..f7b44e1940 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -188,7 +188,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function overflowHistorySeed(): SessionEvent[] { +function overflowHistorySeed(): readonly SessionEvent[] { const session = Session.create(SessionId('overflow-history-seed')) for (let turn = 1; turn <= 2; turn += 1) { const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' @@ -215,7 +215,7 @@ function overflowHistorySeed(): SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - return [...session.snapshotEvents()] + return session.snapshotEvents() } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { @@ -250,7 +250,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() const precedingResult = events.findLast(event => @@ -282,7 +282,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( (e): e is SurfaceEvent => @@ -356,7 +356,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, )! diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index d5d958b3fc..84397a5ada 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -442,7 +442,7 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) - const reloaded = Session.create(SessionId('stale-orphan'), [...original.snapshotEvents()]) + const reloaded = Session.create(SessionId('stale-orphan'), original.snapshotEvents()) const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) @@ -461,7 +461,7 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.snapshotEvents()]) + const reloaded = Session.create(SessionId('reloaded-orphan'), original.snapshotEvents()) const agent = fakeAgent(reloaded, () => () => undefined) await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() @@ -716,7 +716,7 @@ describe('compactNow transaction and failure classification', () => { const reserve = vi.fn(() => testCase.release) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const agent = fakeAgent(testCase.session, reserve) - const before = [...testCase.session.snapshotEvents()] + const before = testCase.session.snapshotEvents() const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() controller.abort(reason) diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index 1235b8fa2c..2fc6bb9dd4 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -254,7 +254,7 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) - const replay = Session.create(session.id, [...session.snapshotEvents()]) + const replay = Session.create(session.id, session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) }) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index ce0118a569..93e6bb3d10 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -139,11 +139,11 @@ function visibleInstructionChanges( ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.snapshotEvents().entries()) { + for (const event of agent.session.snapshotEvents()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { - if (visibleSeqs.has(seq)) visible.set(change.scope, change) + if (visibleSeqs.has(event.seq)) visible.set(change.scope, change) } } for (const message of authorityMessages) { diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index a5cb2ae94c..c24dbcac75 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -68,7 +68,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function finalText(events: SessionEvent[]): string { +function finalText(events: readonly SessionEvent[]): string { const message = events.findLast(event => event.type === 'assistant/message') if (message?.type !== 'assistant/message') return '' return message.data.message.content @@ -84,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.snapshotEvents()])).toContain(PROBE) + expect(finalText(live.agent.session.snapshotEvents())).toContain(PROBE) }, 120_000) it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => { @@ -96,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.snapshotEvents()])).toContain(NESTED_PROBE) + expect(finalText(live.agent.session.snapshotEvents())).toContain(NESTED_PROBE) }, 120_000) it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { @@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - const events = [...live.agent.session.snapshotEvents()] + const events = live.agent.session.snapshotEvents() const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 9855748a73..896f12d532 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -187,7 +187,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace return mountWorkspaceContextPlugin(ctx, config) } -function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { +function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) return { @@ -1115,9 +1115,9 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(root, [...original.session.snapshotEvents()]) + const firstResume = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(root, [...firstResume.session.snapshotEvents()]) + const secondResume = stubAgent(root, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) @@ -1144,7 +1144,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1170,9 +1170,9 @@ describe('workspace context request injection', () => { const original = stubAgent(cwd) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(cwd, [...original.session.snapshotEvents()]) + const firstResume = stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(cwd, [...firstResume.session.snapshotEvents()]) + const secondResume = stubAgent(cwd, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) @@ -1200,7 +1200,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - const resumed = stubAgent(cwd, [...original.session.snapshotEvents()]) + const resumed = stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1237,7 +1237,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1256,7 +1256,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) + const repeated = stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1290,7 +1290,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - const claudeResume = stubAgent(root, [...original.session.snapshotEvents()]) + const claudeResume = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions' @@ -1305,7 +1305,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const restored = stubAgent(root, [...claudeResume.session.snapshotEvents()]) + const restored = stubAgent(root, claudeResume.session.snapshotEvents()) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions' @@ -1340,7 +1340,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1354,7 +1354,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) + const repeated = stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1384,7 +1384,7 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( @@ -1430,7 +1430,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( @@ -1483,7 +1483,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( @@ -1790,7 +1790,7 @@ describe('workspace context request injection', () => { // The first resumed pre-step retains the compatible visible baseline and // appends only the offline file transition needed to reach current state. await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) @@ -3540,7 +3540,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const resumed = stubAgent(root, [...agent.session.snapshotEvents()]) + const resumed = stubAgent(root, agent.session.snapshotEvents()) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -3574,7 +3574,7 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) @@ -4608,7 +4608,7 @@ describe('workspace context inbox synchronization', () => { callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncWorkspaceContext(ctx, original) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await ctx.tools.execute({ signal: testToolSignal, diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index f461998377..ec8976fead 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -304,7 +304,7 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - const resumed = Session.create(SessionId('resumed'), [...original.snapshotEvents()]) + const resumed = Session.create(SessionId('resumed'), original.snapshotEvents()) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 489fd644e2..35e5512e08 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -175,7 +175,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() expect(events .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -534,7 +534,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.snapshotEvents()] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: agent.session.snapshotEvents() }) const forked = new ReactLoopAgent( ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) @@ -603,7 +603,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () expect(errors[0]).toBeInstanceOf(LlmError) expect((errors[0] as LlmError).failure).toEqual(failure) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) // A failed step must not synthesize an assistant message. @@ -625,7 +625,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }]) - expect([...agent.session.snapshotEvents()].some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { @@ -655,7 +655,7 @@ describe('step boundary publication order', () => { const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/start') return - const events = [...subject.snapshotEvents()] + const events = subject.snapshotEvents() const last = events.at(-1) observed.push({ turn: event.data.turn, @@ -691,7 +691,7 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() return { turnStart: e.filter(x => x.type === 'turn/start').length, turnEnd: e.filter(x => x.type === 'turn/end').length, @@ -721,7 +721,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) expect(errors).toEqual([]) @@ -878,7 +878,7 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() const turnStarts = e.filter(x => x.type === 'turn/start').length const turnEnds = e.filter(x => x.type === 'turn/end').length expect(turnStarts).toBe(1) @@ -911,7 +911,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) @@ -940,10 +940,10 @@ describe('turn and step boundary recovery', () => { expect(errors).toEqual([]) // Session contains the observer failure per listener, so the committed turn // remains visible to later observers and executes normally. - const types = [...agent.session.snapshotEvents()].map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'turn/start')).toHaveLength(1) expect(types.filter(t => t === 'turn/end')).toHaveLength(1) - const lastBoundary = [...agent.session.snapshotEvents()].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') + const lastBoundary = agent.session.snapshotEvents().findLast(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') @@ -977,7 +977,7 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') expect(stepEndIdx).toBeGreaterThanOrEqual(0) @@ -1011,7 +1011,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() // Both step/end and turn/end are present — finalization ran to completion. expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) @@ -1041,7 +1041,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. - const e1 = [...agent.session.snapshotEvents()] + const e1 = agent.session.snapshotEvents() expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1) expect(e1.at(-1)?.type).toBe('turn/end') @@ -1050,7 +1050,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - expect([...agent.session.snapshotEvents()].filter(x => x.type === 'turn/end')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1082,7 +1082,7 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. - const resultEvent = [...agent.session.snapshotEvents()].find(e => e.type === 'tool/result') + const resultEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { expect(resultEvent.data.message.source.callId).toBe(ToolCallId('c1')) @@ -1146,7 +1146,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1194,7 +1194,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1244,7 +1244,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) // The post-listener cancellation check catches disposal before any step or LLM call. - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1291,7 +1291,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1336,7 +1336,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 101e082f99..d836d6e8f9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -56,8 +56,8 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } describe('agent/pre-step', () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 0158a2fa15..d3235a3970 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1526,7 +1526,7 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.snapshotEvents()] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: agent.session.snapshotEvents() }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types over the inherited prefix expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index dd1548cd7c..e8f0bab180 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const usages = [...agent.session.snapshotEvents()] + const usages = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2 diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 3b426c0ca8..d6332965ec 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -650,7 +650,7 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), - seed: [...agent.session.snapshotEvents()], + seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) const agent2 = handle.agent diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 4688a97fa8..320d5ce81f 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -650,7 +650,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) - const events1 = [...a1.session.snapshotEvents()] + const events1 = a1.session.snapshotEvents() const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 8adccaa4b5..cc079ca291 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -41,8 +41,8 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** Build one assistant response containing the supplied tool calls. */ diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 364aa44a61..c2f11e3572 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,7 +16,7 @@ function userText(session: Session, text: string): void { /** From-scratch oracle: replay the log into a fresh session and derive. */ function scratch(session: Session): unknown { - return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.snapshotEvents()]).deriveMessages() + return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), session.snapshotEvents()).deriveMessages() } describe('derived-message cache', () => { diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 4829d58a89..4eef9e7283 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -110,7 +110,7 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.snapshotEvents()]) + const replayed = Session.create(SessionId(`replay-${counter++}`), original.snapshotEvents()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // Every explicit replay grows by exactly one log-only boundary. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) @@ -121,8 +121,8 @@ describe('Session properties', () => { it('replaying a log that already ends in end-seed adds no further marker', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.snapshotEvents()]) - const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.snapshotEvents()]) + const once = Session.create(SessionId(`idem-a-${counter++}`), original.snapshotEvents()) + const twice = Session.create(SessionId(`idem-b-${counter++}`), once.snapshotEvents()) // Lazy resume makes browsing a pickup, so this must not grow per open. expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 05a5398877..f592d8e1aa 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -132,7 +132,7 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('s3-replay'), [...original.snapshotEvents()]) + const replayed = Session.create(SessionId('s3-replay'), original.snapshotEvents()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // The seed verbatim, plus the end-seed event the constructor appends. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) @@ -1141,7 +1141,7 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.snapshotEvents()] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: a.snapshotEvents() }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 60ce0ebd54..3a92246a85 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -479,7 +479,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - const replayed = Session.create(SessionId('replay'), [...original.snapshotEvents()]) + const replayed = Session.create(SessionId('replay'), original.snapshotEvents()) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index ac422b481f..774d04674c 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,7 +43,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => expect(content).not.toContain('draft') // The log records real read/write/edit tool calls (not bash). - const calls = [...agent.session.snapshotEvents()].filter(e => e.type === 'tool/call').map(e => e.data.name) + const calls = agent.session.snapshotEvents().filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') expect(calls).toContain('edit') diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index b324c85096..0ac97a7a0b 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { - return [...agent.session.snapshotEvents()] + return agent.session.snapshotEvents() .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), @@ -340,7 +340,7 @@ describe('fold onto the downstream decision', () => { expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. - const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.message.content[0].isError)).toBe(true) expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'nope' }]) }) @@ -364,7 +364,7 @@ describe('fold onto the downstream decision', () => { const found = reminders(agent) expect(found).toHaveLength(1) expect(found[0]!.text).toContain('repeating the exact same tool call') - const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'replaced' }]) }) }) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index a402f8ae02..45aeca1050 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,7 +12,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') + const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) @@ -25,7 +25,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') + const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) } @@ -37,7 +37,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) - const full = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const full = session.snapshotEvents().find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) } @@ -48,7 +48,7 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) - const sparse = [...session2.snapshotEvents()].find(e => e.type === 'hook/result') + const sparse = session2.snapshotEvents().find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) expect('stderrSummary' in sparse.data).toBe(false) @@ -63,7 +63,7 @@ describe('hook/* session events', () => { // An explicit decision wins over the continue:false fallback. appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) - const decisions = [...session.snapshotEvents()] + const decisions = session.snapshotEvents() .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) @@ -75,7 +75,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') } @@ -87,7 +87,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) } @@ -98,8 +98,8 @@ describe('hook/* session events', () => { appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' }) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) - const invoked = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') - const result = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const invoked = session.snapshotEvents().find(e => e.type === 'hook/invoked') + const result = session.snapshotEvents().find(e => e.type === 'hook/result') expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1') expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') }) diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index b92900e01c..fb24106d0e 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -72,8 +72,8 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index a6e032d353..af39599dd2 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -54,7 +54,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 200de375ba..bdf476e0fe 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -56,7 +56,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 6aad28fff0..04217b1ee3 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -44,7 +44,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index 403451a49a..e1dc3a13b5 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -288,7 +288,7 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) - const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.snapshotEvents()], meta: {} }) + const child = ctx.sessions.prepare(SessionId('seeded'), { seed: parent.snapshotEvents(), meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) ctx.sessions.announce(child) @@ -307,7 +307,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) - const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.snapshotEvents()], meta: {} }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: donor.snapshotEvents(), meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], @@ -335,7 +335,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { - seed: [...parent.snapshotEvents()], + seed: parent.snapshotEvents(), meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, }) await ctx.plugin({ diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index ac54cc7c06..7923382bc8 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -60,13 +60,13 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** Find a session event by type, narrowed; throws when absent. */ function findEvent( - log: SessionEvent[], + log: readonly SessionEvent[], type: T, position: 'first' | 'last' = 'first', ): Extract { diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index 7baa1069fd..7bad2d991e 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -67,7 +67,7 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { /** Extra inputs the spawn and fork providers supply to the shared driver. */ export interface InProcessRunOptions { /** Completed-turn seed for fork, or undefined for a fresh spawn. */ - readonly seed?: SessionEvent[] + readonly seed?: readonly SessionEvent[] } /** Error used when cancellation wins before the child publication boundary. */ diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index a30107342e..e9e1a1dda2 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -141,7 +141,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') - const seed = [...parent.session.snapshotEvents()] + const seed = parent.session.snapshotEvents() setSandboxMode(parent.session, 'read-only') script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index a4e4cb61f6..cadddf8814 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -201,7 +201,7 @@ describe('startInProcessRun', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const seed = parent.session.snapshotEvents().slice() + const seed = parent.session.snapshotEvents() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result expect(text(result.output)).toBe('child answer') diff --git a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index ee2c79e1e1..8a17743341 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( // The parent's log records the subagent tool/call + its result (not the // child's internal steps). - const events = [...parent.session.snapshotEvents()] + const events = parent.session.snapshotEvents() const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) }, 180_000) diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index 1b5c90bb07..4190395023 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -24,8 +24,8 @@ export function seedDescriptorTurn( childId: SessionId, seed: readonly SessionEvent[] | undefined, descriptor: SubagentDescriptorData, -): SessionEvent[] { +): readonly SessionEvent[] { const staged = Session.create(childId, seed) staged.append('subagent/descriptor', descriptor) - return [...staged.snapshotEvents()] + return staged.snapshotEvents() } diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index e3d2001554..c8f52e7c80 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,7 +66,7 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const before = [...session.snapshotEvents()] + const before = session.snapshotEvents() expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) expect(session.snapshotEvents()).toEqual(before) From 5156226cb9795b89fc76e8b58414377b10e22797 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 14:12:33 +0800 Subject: [PATCH 034/429] docs(notes): record the hero glow removal, consolidating the one-axis-scroll note The 2026-08-04 bug-fix note owned the overflow-x clip that existed only for the glow's bleed; with the glow, the clip, and its test all gone the note is fully superseded. The new note preserves its rationale and the reintroduction condition for future bleeding chrome. --- ...-04-conversation-column-one-axis-scroll.md | 37 ------------------- ...-conversation-column-one-axis-scroll.zh.md | 37 ------------------- ...26-08-28-remove-hero-input-glow.i18n.yaml} | 6 +-- .../2026-08-28-remove-hero-input-glow.md | 29 +++++++++++++++ .../2026-08-28-remove-hero-input-glow.zh.md | 29 +++++++++++++++ 5 files changed, 61 insertions(+), 77 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md rename .agents/notes/implemented/{bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml => simplification/2026-08-28-remove-hero-input-glow.i18n.yaml} (54%) create mode 100644 .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md create mode 100644 .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md deleted file mode 100644 index 9a487c506a..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: The conversation column scrolls on one axis - -Status: implemented - -English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) - -## Problem - -Narrowing the center column — by the window or by the sidebar drag — put a horizontal scrollbar under the whole conversation column on the hero. The bleeding element is the hero's decorative backdrop ellipse: `.heroGlow` is sized `1051/776` of the hero box so its blur scales in userSpace with the input card, which means it reaches past the column whenever the column is narrower than the glow. - -That bleed is by construction and stays. What made it user-visible is the scroll container it sits in. `[data-conversation-scroll]` declared `overflow-y: auto` and left the other axis at its initial `visible`, and a box that scrolls in one axis computes `visible` to `auto` in the other. Every column narrower than the glow therefore offered a real horizontal scroll range — measured at 24–95px across the widths a laptop actually produces. - -## Decision - -`.scrollBody` declares `overflow-x: hidden`. The column states that it is a one-axis scroller instead of leaving the second axis to be derived. - -Clipping does not change. `overflow-y: auto` had already made the box a scroll container that clips both axes, so the declaration withdraws only the scrollbar and the user gesture; the glow keeps its bleed, its blur radius, and the same painted extent, and the column keeps its vertical scroll. Nothing in the composer chain moves. - -## Alternatives considered - -**Size the glow to fit the column.** Rejected. The glow's width is what scales its `stdDeviation="50"` blur with the input card (figma 313:14109); constraining it would make the blur tighten as the column narrows, which is a visual regression to fix a scrollbar. - -**Wrap the glow in a clipping box.** Rejected. It adds a box whose only job is to undo an overflow the column already clips, and it leaves the derived `overflow-x: auto` in place for the next element that bleeds — the transcript is full of candidates. - -**Rely on the frame's `.centerCol { overflow: hidden }`.** It cannot help. That clip is outside the scroll container, so it hides the glow's overhang at the column border while the container inside it still scrolls to reach it. The reported bar was that container's. - -**Assert `scrollWidth === clientWidth` in the test.** Rejected as the signal, because it does not distinguish the states: `hidden` clips the bleed rather than reflowing it away, so the scroll range reads the same on both sides of the fix. Only refusing a user gesture differs, which is what the scenario measures. - -## Testing - -[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all. - -Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived. - -## Consequences - -The conversation column no longer offers a horizontal scrollbar at any width, and decorative bleed in the composer chain is now clipped rather than exposed as scroll range. The cost is that genuinely wide content under this column is clipped instead of reachable by scrolling: any such surface owns its own scroller, as the markdown code block and the trajectory table already do. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md deleted file mode 100644 index b86f86f557..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: 会话列只在一个轴上滚动 - -Status: implemented - -[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 - -## 问题 - -当中间列被拉窄——无论是拖窗口还是拖侧边栏——hero 态的整条会话列下方就会出现一条横向滚动条。溢出的元素是 hero 的装饰性背景椭圆:`.heroGlow` 的宽度取 hero 盒子的 `1051/776`,好让它的模糊在 userSpace 中随输入卡片一同缩放;这也意味着只要列比它窄,它就会伸出列外。 - -这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。 - -## 决策 - -`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。 - -裁剪行为不变。`overflow-y: auto` 早已使该盒子成为在两个轴上都裁剪的滚动容器,因此这条声明收回的只是滚动条和用户手势;椭圆保留它的外溢、模糊半径和同样的绘制范围,列也保留纵向滚动。输入区那条链路上没有任何东西移动。 - -## 曾考虑的替代方案 - -**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。 - -**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——transcript(文本记录)里这样的候选者不少。 - -**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。 - -**在测试里断言 `scrollWidth === clientWidth`。** 作为判据被否决,因为它区分不出两种状态:`hidden` 裁剪外溢,而不是把它重排掉,所以修复前后读到的滚动范围一样。唯一有差别的是拒绝用户手势,这正是该场景所测量的。 - -## 测试 - -[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上于列上触发横向滚轮事件并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。 - -两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮事件根本没送达。 - -## 后果 - -会话列在任何宽度下都不再给出横向滚动条,输入区链路上的装饰性外溢从暴露为滚动范围改为被裁剪。代价是这条列下真正过宽的内容会被裁掉而非可滚动够到:这类界面各自拥有自己的滚动容器,markdown 代码块和轨迹表格已经如此。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.i18n.yaml similarity index 54% rename from .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.i18n.yaml index 9f804b5ea9..484573a799 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.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/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d -2026-08-04-conversation-column-one-axis-scroll.zh.md: b86f86f55757dff4fddab4c4e2ac64fa7c19fe59 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md +2026-08-28-remove-hero-input-glow.md: 929fce91d38ed5436421e91666e91328389a1302 +2026-08-28-remove-hero-input-glow.zh.md: e8a32c8dc60feae344be587e4bcc4b8a4f8a2d87 diff --git a/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md b/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md new file mode 100644 index 0000000000..929fce91d3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md @@ -0,0 +1,29 @@ +# Agent Note: The hero input glow is removed + +Status: implemented + +English | [中文](2026-08-28-remove-hero-input-glow.zh.md) + +## Problem + +The New Session hero painted a decorative backdrop ellipse (`HeroGlow`, figma 313:14109) under the input card: a blurred blue gradient sized `1051/776` of the hero box so its `stdDeviation="50"` blur scaled with the card. On the shipped token sheets the ellipse read as stray blue tint rather than intentional chrome, and its by-construction bleed past the conversation column forced clipping scaffolding onto the column itself. + +That scaffolding existed because a box that scrolls in one axis computes the other axis's initial `visible` to `auto`: the glow's overhang gave `[data-conversation-scroll]` a real 24–95px horizontal scroll range on laptop widths, patched by declaring `overflow-x: hidden` on `.scrollBody` (2026-08-04). This note supersedes and consolidates that bug-fix note. + +## Decision + +`HeroGlow` is deleted with its positioning scaffolding: the component and its seat in `EmptyHero.tsx`, the glow z-index carve-outs in `ConversationRoot.module.css`, and the `.scrollBody { overflow-x: hidden }` clip, which had no owner other than the glow's bleed. The scroll body's horizontal axis returns to its derived value, and nothing under the column currently bleeds past it. + +The e2e scenario `conversation-column-overflow.e2e.ts` and its golden are deleted with the glow: the test's vacuity guard asserted the glow still bled past the column at narrow stops, so it cannot pass — by design — once nothing bleeds. + +## Alternatives considered + +**Keep the glow and retune its color.** Rejected. The tint was not a token mistake to correct; the product read is that the homepage input carries no backdrop chrome at all. + +**Keep `overflow-x: hidden` as a defensive clip.** Rejected. With the glow gone the declaration has no current owner, and the repo requires one; a silent clip would also hide the next accidental bleed instead of surfacing it in review. + +**Keep the overflow test against future bleed.** Rejected. Its vacuity guard requires a presently-bleeding element, so the scenario cannot express "nothing bleeds" without inverting into a different test; the composer geometry golden already pins the scroll body's `overflow` axes per tab. + +## Consequences + +The hero stack is plain chrome above the shared input card, and 67 lines of glow component, seat wiring, and clip scaffolding are gone. The cost is the standing guard: the conversation column is again a one-axis scroller only by construction, so a future decorative element that bleeds past the column will re-derive `overflow-x: auto` and surface a horizontal scrollbar. Whoever reintroduces bleed must restore an explicit one-axis clip on `.scrollBody` and a gesture-level regression test — asserting `scrollWidth === clientWidth` is not a substitute, because a clip hides the range without reflowing it away and only the refused wheel gesture distinguishes the states. The composer tab geometry golden records the current `overflow auto/auto` reading and will flag the derivation flipping back. diff --git a/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md b/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md new file mode 100644 index 0000000000..e8a32c8dc6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md @@ -0,0 +1,29 @@ +# Agent Note: The hero input glow is removed + +Status: implemented + +[English](2026-08-28-remove-hero-input-glow.md) | 中文 + +## Problem + +New Session 首页曾在输入卡片下方绘制一个装饰性背景椭圆(`HeroGlow`,figma 313:14109):一个模糊的蓝色渐变,尺寸为 hero 盒子的 `1051/776`,使其 `stdDeviation="50"` 模糊随卡片缩放。在实际交付的 token 表下,这个椭圆看起来是意外的蓝色沾染而非有意的装饰,并且它按构造就会溢出会话列,迫使列本身背上裁剪脚手架。 + +脚手架的由来:单轴滚动的盒子会把另一轴初始的 `visible` 推导为 `auto`,glow 的溢出让 `[data-conversation-scroll]` 在笔记本宽度下出现 24–95px 的真实横向滚动范围,当时(2026-08-04)靠在 `.scrollBody` 上声明 `overflow-x: hidden` 修补。本 note 取代并合并了那个 bug-fix note。 + +## Decision + +`HeroGlow` 连同其定位脚手架一并删除:组件本体及其在 `EmptyHero.tsx` 中的座位、`ConversationRoot.module.css` 中为 glow 开的 z-index 例外,以及 `.scrollBody { overflow-x: hidden }` 裁剪——后者除 glow 的溢出外没有任何持有者。滚动主体的横轴回到推导值,当前列下没有任何元素溢出。 + +e2e 场景 `conversation-column-overflow.e2e.ts` 及其 golden 随 glow 一并删除:该测试的空洞防护(vacuity guard)断言 glow 在窄档位仍然溢出列,因此一旦没有任何东西溢出,它按设计就无法通过。 + +## Alternatives considered + +**保留 glow 只调整颜色。** 拒绝。这不是一个待修正的 token 错误;产品判断是首页输入卡片根本不应携带背景装饰。 + +**保留 `overflow-x: hidden` 作为防御性裁剪。** 拒绝。glow 删除后该声明没有当前持有者,而仓库要求每项内容都有;静默裁剪还会把下一次意外溢出藏起来,而不是在评审中暴露它。 + +**保留 overflow 测试防范未来溢出。** 拒绝。它的空洞防护要求当下存在一个正在溢出的元素,场景无法在不改写成另一个测试的前提下表达"没有东西溢出";composer 几何 golden 已经按 tab 钉住了滚动主体的 `overflow` 两轴取值。 + +## Consequences + +hero 栈成为共享输入卡片上方的朴素装饰,glow 组件、座位接线和裁剪脚手架共 67 行被删除。代价是失去常驻防线:会话列重新只靠构造保持单轴滚动,未来任何溢出列的装饰元素都会重新推导出 `overflow-x: auto` 并出现横向滚动条。重新引入溢出者必须在 `.scrollBody` 上恢复显式单轴裁剪并补上手势级回归测试——断言 `scrollWidth === clientWidth` 不能替代,因为裁剪只是隐藏范围而非将其回流消除,只有被拒绝的滚轮手势能区分两种状态。composer tab 几何 golden 记录了当前 `overflow auto/auto` 的读数,推导翻转回去时会报警。 From 47e6448e236ea887c18fed7b77f27c0a78935a51 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 14:17:21 +0800 Subject: [PATCH 035/429] perf(session): avoid unnecessary event snapshots --- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +-- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- .../context/agent-instructions/src/state.ts | 8 ++--- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../interaction/user-approval/src/index.ts | 34 ++++++------------- .../user-approval/tests/approval.spec.ts | 24 ++++++++----- .../src/workspace-access.ts | 3 -- 14 files changed, 43 insertions(+), 52 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4ca92ba463..f5f21346b9 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: 2ed8dd593e69b00d659ac71e96e856456a5e9185 -config-catalog.zh.md: dd22bea8e2f47179d9458cfdba1f74726fb78ae2 +config-catalog.md: 29c1d2c018f53c9bf9e3a4a1e78f8bc4d6d7247a +config-catalog.zh.md: c6f173718e86c3b4fcbedace292551532aec95a1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2ed8dd593e..29c1d2c018 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3203,7 +3203,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dd22bea8e2..c6f173718e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3205,7 +3205,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 08c524b34f..076760fb44 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: b3c7ee3d13d1098d560a99554491a9890a4083b5 -persistence-catalog.zh.md: 730d4b0db3752979955ecedfa94985c0f03066bd +persistence-catalog.md: 71c350b9d17ed7735e7b3bedbec6256301a531e9 +persistence-catalog.zh.md: 4b5f996276b9b56bd969ea8fdf2bf277e8b4da2e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b3c7ee3d13..71c350b9d1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -178,7 +178,7 @@ Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/inter * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 730d4b0db3..4b5f996276 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -180,7 +180,7 @@ export type SessionEvent = { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 47c6c79f0a..65231608af 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: c6c2395ba2535b1d180c82e63dc31a1ed6c1e087 -session.zh.md: df2bac57a4a847df62467ed772386eca24c02183 +session.md: 208820e006b148e2eb0f346dc29967171737569c +session.zh.md: 7eecf562595e92778017b954fb45dd86c6dcb9cf diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index c6c2395ba2..208820e006 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -626,7 +626,7 @@ resolveAgent(sessionId: SessionId): Promise * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> /** * Read all visible Session rows without resuming an Agent. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index df2bac57a4..7eecf56259 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -630,7 +630,7 @@ resolveAgent(sessionId: SessionId): Promise * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> /** * Read all visible Session rows without resuming an Agent. diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index 93e6bb3d10..fb25e262f9 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -137,13 +137,13 @@ function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { - const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const event of agent.session.snapshotEvents()) { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue + for (const seq of agent.session.surface.nodes) { + const event = agent.session.eventAt(seq) + if (event?.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { - if (visibleSeqs.has(event.seq)) visible.set(change.scope, change) + visible.set(change.scope, change) } } for (const message of authorityMessages) { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0730e5f8b4..4e188f688c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1335,7 +1335,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the live Agent or the stable Session-domain failure.', }, { - signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }>', description: 'Inspect one attached or persisted Session without activating its Agent.', parameters: [{ name: 'sessionId', description: 'durable Session identity.' }, { name: 'signal', description: 'optional caller cancellation for persistence reads.' }], returns: 'the current attached state or persisted header and event prefix.', diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 93c4a605ab..1aec3ded27 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -66,22 +66,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t /** Model-facing statement for an interactive policy that may still fail closed. */ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' -/** - * The session's approval-policy override: the last `approval/policy` event in - * the log, or undefined when the session never switched (callers apply the - * plugin's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the policy of the last switch event, or undefined without one. - */ -export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'approval/policy') return event.data.policy - } - return undefined -} - /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -89,9 +73,9 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv * commit/replay boundary, so a bare event appended between turns is * indistinguishable from a crash tail and silently dropped on reload. */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type +function hasOpenTurn(session: Session): boolean { + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + const type = session.eventAt(seq)?.type if (type === 'turn/start') return true if (type === 'turn/end') return false } @@ -221,7 +205,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.snapshotEvents())) { + if (!hasOpenTurn(session)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -257,7 +241,11 @@ export class ApprovalService extends Service { * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.snapshotEvents()) + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + const event = session.eventAt(seq) + if (event?.type === 'approval/policy') return event.data.policy + } + return undefined } /** diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 868ba4f384..70e1abcf4e 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -7,22 +7,26 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { ApprovalOutcome, ApprovalRequest, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' /** * A minimal Agent stand-in — the service only reaches `agent.session.append` - * and folds `snapshotEvents()`. Seeded inside an open turn by default (request()'s + * and indexed log reads. Seeded inside an open turn by default (request()'s * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] + const events: Array<{ type: string; data?: Record }> = [...seed] const agent = { session: { - snapshotEvents: () => seed, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], append: (type: string, data: Record) => { - appended.push({ type, data }) - return { type, data } as unknown as SessionEvent + const event = { type, data } + events.push(event) + appended.push(event) + return event as unknown as SessionEvent }, }, } as unknown as Agent @@ -164,7 +168,8 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - snapshotEvents: () => [{ type: 'turn/start' }], + seq: 1, + eventAt: () => ({ type: 'turn/start' }), append: () => { throw failure }, }, } as unknown as Agent @@ -365,11 +370,12 @@ describe('approval policy (the approval/policy fold)', () => { } it('folds to the last event, or undefined without one', () => { + const service = new ApprovalService(new Context(), {}) const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.snapshotEvents())).toBeUndefined() + expect(service.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('ask') + expect(service.overrideOf(session)).toBe('ask') expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) @@ -458,7 +464,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('never') + expect(ctx.approval.overrideOf(session)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index 697adc5951..abda7f8b93 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -8,7 +8,6 @@ import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionId, - type SessionEvent, type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' @@ -24,7 +23,6 @@ import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader - readonly events: readonly SessionEvent[] /** The caller's own-session boundary fold (the `turnBoundary` projection). */ readonly boundary: TurnBoundaryProjection | undefined } @@ -66,7 +64,6 @@ function callerOf(exec: ToolRunContext, ctx: Context): Caller { return { id: agent.session.id, header: agent.session.header, - events: agent.session.snapshotEvents(), boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } From 32d681f0237c3672f1bd93b437a26b034af80ac0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 14:55:46 +0800 Subject: [PATCH 036/429] fix(subagent): preserve empty restored model selection Detect explicit empty seeds through the existing end-seed marker and remove the redundant Session.seeded API. --- ...08-24-user-authorized-subagent-model-routes.i18n.yaml | 4 ++-- .../2026-08-24-user-authorized-subagent-model-routes.md | 2 +- ...026-08-24-user-authorized-subagent-model-routes.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 ++-- docs/subsystems/session.md | 7 ------- docs/subsystems/session.zh.md | 7 ------- packages/core/session/README.i18n.yaml | 4 ++-- packages/core/session/README.md | 2 -- packages/core/session/README.zh.md | 2 -- packages/core/session/src/index.ts | 9 --------- packages/core/session/tests/session.spec.ts | 2 -- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/subagent/tool-subagent/README.i18n.yaml | 4 ++-- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 4 +++- .../tool-subagent/tests/model-selection-settings.spec.ts | 4 ++++ 17 files changed, 20 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 2a8c4bbcb5..697f943349 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89 -2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48 +2026-08-24-user-authorized-subagent-model-routes.md: ef6f90d2cc1ec79e1381d75fd8cc25c568af3067 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 23519bfc6619718e1cbf98dbad65eccd90353c0a diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index fce0026f65..ef6f90d2cc 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -12,7 +12,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. -A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session. The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 2cdf42dd33..23519bfc66 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -12,7 +12,7 @@ Status: implemented Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 -设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。 固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 65231608af..cbc8c58b24 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: 208820e006b148e2eb0f346dc29967171737569c -session.zh.md: 7eecf562595e92778017b954fb45dd86c6dcb9cf +session.md: 9e19596f702f5ca7f219be46fb122ec9f00659af +session.zh.md: 238638f868047fba31c1891ca678a29a84d5f834 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 208820e006..9e19596f70 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -379,13 +379,6 @@ declare class Session { * holds an ordinary published write. */ readonly firstLiveSeq: number; - /** - * Whether construction received a replay, fork, or restored seed. - * Every seeded lifecycle has a `session/end-seed` marker after construction; - * this field distinguishes an explicitly empty seed from a fresh session - * without scanning the log. - */ - readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7eecf56259..238638f868 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -381,13 +381,6 @@ declare class Session { * holds an ordinary published write. */ readonly firstLiveSeq: number; - /** - * Whether construction received a replay, fork, or restored seed. - * Every seeded lifecycle has a `session/end-seed` marker after construction; - * this field distinguishes an explicitly empty seed from a fresh session - * without scanning the log. - */ - readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 20d1182786..74d7410759 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: f490c53c846c00f8d0c91b7ad7fae3f688522a31 -README.zh.md: 0f32bc5ba04e6da2bc8907280ef2c8ea9dc81b9a +README.md: b583941ef8f7fb0760070b2e2168a6e9806ba9c2 +README.zh.md: 6a1f5d6993af483c027b715d1e3bfdb3b06f7ab4 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index f490c53c84..b583941ef8 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -53,8 +53,6 @@ Surface events (`user/message`, `assistant/message`, `tool/result`) must declare `session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`. -`session.seeded` distinguishes construction with a replay, fork, or restored seed—including an explicitly empty seed—from a fresh session without scanning for `session/end-seed`. - ### Fork a session `ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 0f32bc5ba0..6a1f5d6993 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -53,8 +53,6 @@ session.deriveMessages() // the derived model history `session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 -`session.seeded` 可区分以 replay、fork 或恢复 seed(包括显式空 seed)构造的会话与全新会话,无需扫描 `session/end-seed`。 - ### 派生会话的 fork `ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 3d934b0884..cc4fc60cd3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -469,14 +469,6 @@ export class Session { */ readonly firstLiveSeq: number - /** - * Whether construction received a replay, fork, or restored seed. - * Every seeded lifecycle has a `session/end-seed` marker after construction; - * this field distinguishes an explicitly empty seed from a fresh session - * without scanning the log. - */ - readonly seeded: boolean - /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -543,7 +535,6 @@ export class Session { } } this.firstLiveSeq = this.log.length - this.seeded = seed !== undefined this.header = restoredHeader ?? snapshotSessionHeader(id, header) // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f592d8e1aa..cbce08d5ff 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -142,11 +142,9 @@ describe('Session', () => { it('marks an explicitly empty seed without marking a fresh session', () => { const fresh = Session.create(SessionId('fresh-empty')) - expect(fresh.seeded).toBe(false) expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) - expect(resumed.seeded).toBe(true) expect(resumed.firstLiveSeq).toBe(0) expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4e188f688c..b629dee500 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4740,7 +4740,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n readonly seeded: boolean;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): 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}', + 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 eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): 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', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 660bf0ba4d..bfe7d2aa8b 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3 -README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b +README.md: 8dabfe42e2aa200294820dfb0c3bc366134d6ac9 +README.zh.md: b3efaae8355d998cb0f47735fac98a368c16277b diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5520e98ddc..8dabfe42e2 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -64,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du ### Selecting a child LLM -Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. +Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each fresh top-level Session is composed. A restored Session without a recorded policy remains disabled, including an explicitly empty restore. When enabled, the non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 069eca4f8c..b3efaae835 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 +设置 `modelSelectionSettings: true`,即可在组合每个全新顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。没有已记录策略的恢复 Session 会保持禁用,包括显式为空的恢复。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 387c948d53..8f693c58b2 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -619,6 +619,8 @@ export function apply(ctx: Context, config: Config): void { } const selectForAgent = (agent: NonNullable): ModelSelectionPolicy | undefined => { + const freshSession = agent.session.firstLiveSeq === 0 + && agent.session.eventAt(0)?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' @@ -629,7 +631,7 @@ export function apply(ctx: Context, config: Config): void { allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(ctx.sessionProjections, parent.session) - } else if (agent.session.firstLiveSeq === 0) { + } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 1aa93df37c..6926b64888 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -314,6 +314,10 @@ describe('SubagentModelSelectionConfig', () => { enabled: true, allowedModels: ALLOWED_MODELS, }) + const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] }) + expect(selectable(ctx, resumedEmpty)).toBe(false) + expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() From 1b6f9a1b5182c9f482a73863b8fea0a6656fbc0a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 14:58:11 +0800 Subject: [PATCH 037/429] refactor(session-telemetry): simplify capture replay --- packages/session/session-telemetry/src/coordinator.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index 8725888d7b..b558cedf8a 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -137,14 +137,9 @@ export class SessionTelemetryCoordinator { */ captureSession(session: Session, throughSeq?: number): void { const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 - const length = session.seq // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. - for (let seq = 0; seq < length; seq++) { - const event = session.eventAt(seq) - if (event === undefined) { - throw new Error(`session telemetry capture cannot read seq ${String(seq)} below captured length ${String(length)}`) - } + for (const event of session.snapshotEvents()) { if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { if (event.seq <= cursor) this.track(session, event) From 7222e17dc03daf46b58eedbbdeace107a258ff0b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 28 Aug 2026 15:02:04 +0800 Subject: [PATCH 038/429] fix(tool-fs): accept extension-less attachment paths in read_image --- ...e-extensionless-attachment-paths.i18n.yaml | 6 + ...ad-image-extensionless-attachment-paths.md | 27 +++ ...image-extensionless-attachment-paths.zh.md | 27 +++ docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- .../attachment-local/tests/store.spec.ts | 22 ++ .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 3 +- packages/attachment/attachment/README.zh.md | 3 +- packages/attachment/attachment/src/index.ts | 1 + packages/attachment/attachment/src/sniff.ts | 39 ++++ .../attachment/attachment/tests/sniff.spec.ts | Bin 0 -> 1464 bytes 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 | 52 +++-- packages/fs/tool-fs/tests/read-image.spec.ts | 190 +++++++++++++++++- .../sdk/bash-tool/tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../sdk/text-turn/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 4 +- .../both-mode-turn/system-prompt.expected.md | 2 +- .../both-mode-turn/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 4 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../lsp-definition/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../replay.override.json | 29 +++ .../session.jsonl | 48 +++++ .../snapshot.yml | 10 + .../workspace/red.png | Bin 0 -> 69 bytes .../ptc-read-image/system-prompt.expected.md | 2 +- .../ptc-turn/system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../ralph-loop/tool-schemas.1.expected.json | 2 +- .../ralph-loop/tool-schemas.2.expected.json | 2 +- .../replay.override.json | 29 +++ .../read-image-attachment-path/session.jsonl | 38 ++++ .../read-image-attachment-path/snapshot.yml | 9 + .../workspace/red.png | Bin 0 -> 69 bytes .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../text-turn/tool-schemas.expected.json | 2 +- .../web-fetch/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../web/ptc-round/system-prompt.expected.md | 2 +- 62 files changed, 568 insertions(+), 71 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md create mode 100644 .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md create mode 100644 packages/attachment/attachment/src/sniff.ts create mode 100644 packages/attachment/attachment/tests/sniff.spec.ts create mode 100644 snapshots/session/ptc-read-image-attachment-path/replay.override.json create mode 100644 snapshots/session/ptc-read-image-attachment-path/session.jsonl create mode 100644 snapshots/session/ptc-read-image-attachment-path/snapshot.yml create mode 100644 snapshots/session/ptc-read-image-attachment-path/workspace/red.png create mode 100644 snapshots/session/read-image-attachment-path/replay.override.json create mode 100644 snapshots/session/read-image-attachment-path/session.jsonl create mode 100644 snapshots/session/read-image-attachment-path/snapshot.yml create mode 100644 snapshots/session/read-image-attachment-path/workspace/red.png diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml new file mode 100644 index 0000000000..02d0e3310e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.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-28-read-image-extensionless-attachment-paths.md +2026-08-28-read-image-extensionless-attachment-paths.md: be6acb2ca336a976bcbf0c50efda0b933fb06df6 +2026-08-28-read-image-extensionless-attachment-paths.zh.md: a4e1e677a46dd72661944e09101994358c829877 diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md new file mode 100644 index 0000000000..be6acb2ca3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md @@ -0,0 +1,27 @@ +# Agent Note: read_image accepts extension-less attachment object paths + +Status: implemented + +English | [中文](2026-08-28-read-image-extensionless-attachment-paths.zh.md) + +## Problem + +Model-visible image descriptors name the normalized attachment's local read-only path, and normalized objects are content-addressed files without an image extension. `read_image` mapped `file_path` to a media type by extension alone and refused everything else, so passing the descriptor's own path back produced `read_image only accepts PNG/JPEG/WebP/GIF paths` for an image the store had already validated and persisted. The model's only workarounds were copying the object to a renamed file or re-uploading it. + +## Decision + +The extension stays the declared media type when it names one of the four supported formats, and every existing gate and diagnostic on that branch is unchanged. A path without an extension is no longer refused up front: the tool reads the bytes under the existing byte cap and identifies the container with `sniffImageMediaType`, a pure file-signature helper exported by the attachment Service Definition package. The sniffed type then passes through the same deployment media-type policy and `saveImage` admission, so the store's full decode remains the authority; a path whose extension names a non-image format is still refused before any I/O. + +Reading an object path re-saves its bytes rather than short-circuiting through a reverse path lookup. Normalization passes an already-normalized image through byte-identically, so the re-save deduplicates to the same content-addressed reference; a store test pins that idempotency for a re-encoded object. No reference proof is required at the tool: object paths are unguessable content digests, the published objects are already readable through the mounted filesystem (Bash included), and the session-reference gate continues to protect the remote client RPC, which is the boundary where an attachment id alone grants bytes. + +Two diagnostics are sharpened alongside: `INVALID_IMAGE` admission failures now name the offending path instead of surfacing the store's bare message, and an extension-less admission mismatch blames the file signature rather than a nonexistent extension. + +## Consequences + +The model reads a descriptor's normalized attachment path directly, in native and PTC modes, without copying, renaming, or re-uploading; recorded scenarios `read-image-attachment-path` and `ptc-read-image-attachment-path` pin both flows end to end, including deduplication to the stored reference. Ordinary extension-less image files become readable through the same content identification, while a wrong-extension path keeps its fast pre-I/O refusal and repair message. Any future consumer accepting extension-less image paths can reuse `sniffImageMediaType` instead of a second signature table. + +## Alternatives considered + +**Name objects with an extension on disk.** This changes the storage layout, dedup commit path, corrupt checks, export archive, and every committed object-path expectation, only to satisfy the tool's extension heuristic, and the heuristic itself — extension as media-type authority — stays wrong. The store already treats decoded content as the authority. + +**Instruct the model to copy the object before reading.** Prompt guidance does not stop direct calls, so the misleading refusal survives; the copy adds a filesystem write and a turn for a pure read; and the re-read commits the identical bytes to the same reference anyway, so the copy changes nothing but cost. diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md new file mode 100644 index 0000000000..a4e1e677a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md @@ -0,0 +1,27 @@ +# Agent Note: read_image 接受无扩展名的附件对象路径 + +Status: implemented + +[English](2026-08-28-read-image-extensionless-attachment-paths.md) | 中文 + +## 问题 + +模型可见的图片描述会给出规范化附件的本地只读路径,而规范化对象是不带图片扩展名的内容寻址文件。`read_image` 仅凭扩展名把 `file_path` 映射到媒体类型并拒绝其余一切,于是把描述里的路径原样传回会得到 `read_image only accepts PNG/JPEG/WebP/GIF paths`,尽管该图片已经通过存储校验并持久保存。模型只能把对象复制成改名文件或要求重新上传。 + +## 决定 + +扩展名命名四种受支持格式之一时仍作为声明的媒体类型,该分支的所有既有检查和诊断保持不变。无扩展名的路径不再被提前拒绝:工具在既有字节上限内读取字节,用 `sniffImageMediaType` 识别容器格式,这是附件 Service Definition 包导出的纯文件签名辅助函数。识别出的类型随后经过同一套部署媒体类型政策和 `saveImage` 准入,存储实现的完整解码仍是权威;扩展名声明了非图片格式的路径依旧在任何 I/O 之前被拒绝。 + +读取对象路径会重新保存其字节,而不是通过反向路径查找走捷径。归一化让已归一化的图片字节原样通过,因此重新保存会去重到同一个内容寻址引用;一个存储测试固定了重编码对象的这一幂等性。工具处不需要会话引用证明:对象路径是不可猜测的内容摘要,已发布对象本来就能通过挂载的文件系统(包括 Bash)读到,而会话引用检查继续保护远程客户端 RPC,那才是仅凭附件 id 就能获得字节的边界。 + +顺带收紧了两处诊断:`INVALID_IMAGE` 准入失败现在会指出出错的路径而不是透传存储的裸消息,无扩展名路径的准入不匹配归因于文件签名而不是不存在的扩展名。 + +## 影响 + +模型在 native 和 PTC 两种模式下都可以直接读取描述中的规范化附件路径,无需复制、改名或重新上传;录制场景 `read-image-attachment-path` 和 `ptc-read-image-attachment-path` 端到端固定了这两条流程,包括去重到已存储引用。普通的无扩展名图片文件也通过同一套内容识别变得可读,而扩展名错误的路径保留 I/O 之前的快速拒绝和修复消息。将来任何接受无扩展名图片路径的消费方都可以复用 `sniffImageMediaType`,不必再维护一张签名表。 + +## 考虑过的替代方案 + +**在磁盘上给对象命名加扩展名。** 这会改动存储布局、去重提交路径、损坏检查、导出归档以及每一处已提交的对象路径期望,只为迁就工具的扩展名启发式,而启发式本身(以扩展名为媒体类型权威)依然是错的。存储实现一直以解码内容为权威。 + +**指示模型先复制对象再读取。** 提示词挡不住直接调用,误导性的拒绝依然存在;复制为一次纯读取平添一次文件系统写入和一轮往返;重新读取本来就会把相同字节提交到同一引用,复制除了成本什么也不改变。 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 8e69eaae28..9f3d160b0a 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: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 -attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a +attachment.md: e93e6782eae97811be2060807a867d3036931a8c +attachment.zh.md: a0167cec6d1ddd8a06bdd7501edcb3d1c6ababe5 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 15daa2b8d5..e93e6782ea 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. The model may pass such a path back to `read_image` directly: extension-less files are identified from their file signature, and re-saving a normalized object deduplicates to the same content-addressed reference. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index c74a18f6be..a0167cec6d 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。模型可以把这样的路径直接传回 `read_image`:无扩展名文件按文件签名识别格式,重新保存规范化对象会去重到同一个内容寻址引用。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 392f448802..26a87fcd9d 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: 91ff093e79cc05a2c08b5aa1130378440cd963f3 -tool-catalog.zh.md: 35daf5a7c6b1c27975721c0b9d8ddfe71eaeb803 +tool-catalog.md: f69f6e3650dccc06a5cbe243c26f0411befdc9e2 +tool-catalog.zh.md: 5ac0948c3beb50b9c361ca575029a9a2763716fa diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 91ff093e79..f69f6e3650 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -731,7 +731,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. 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 a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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 { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 35daf5a7c6..5ac0948c3b 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -737,7 +737,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `read_image` -读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。 +读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。无扩展名的路径同样被接受;格式按文件内容检测,因此规范化附件路径可以直接传入,无需复制或重命名。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。 ```json { diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 3ff58eb2da..e1c5a788d6 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -172,6 +172,28 @@ describe('local attachment store', () => { expect(String(saved.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) }) + it('re-saves a re-encoded normalized object byte-identically to the same reference', 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 policy = { maxPixels: POLICY.maxPixels, maxDimension: 2, maxBytes: 1024 * 1024 } + const saved = await saveImageFile(storageRoot, { + data: oversized, mediaType: 'image/png', name: 'big.png', + }, { ...LIMITS, maxImagePixels: 64 }, policy) + + // Normalization passes an already-normalized object through unchanged, so + // reading the object file and saving those bytes again deduplicates: the + // read_image tool can accept object paths without minting new attachments. + const objectBytes = (await readImageFile(storageRoot, saved)).data + const resaved = await saveImageFile(storageRoot, { + data: objectBytes, mediaType: saved.mediaType, name: 'big', + }, { ...LIMITS, maxImagePixels: 64 }, policy) + expect(resaved.attachmentId).toBe(saved.attachmentId) + expect(resaved.bytes).toBe(saved.bytes) + expect(resaved.originalDimensions).toBeUndefined() + }) + 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) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index e67d95604d..aac868951f 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: 01a5d6ee143ff53175dd7327cd6d419af61938a3 -README.zh.md: 1a1d297297a6815ce5338391af0182e471f99000 +README.md: feb8b14a2a46543169820e444994ef7cb1514210 +README.zh.md: 0f088e170cde9fb27032351dc1f9861c9111b6fd diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 01a5d6ee14..feb8b14a2a 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. The pure `sniffImageMediaType` export identifies a supported container from its file signature for consumers that accept extension-less image paths; persisting callers keep the store's full decode authoritative. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map @@ -77,6 +77,7 @@ The service family runs one admission-and-storage flow: every entry point enforc | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | | [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`: canonical-base64 enforcement, then `saveImages` delegation | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | +| [`src/sniff.ts`](src/sniff.ts) | `sniffImageMediaType`: file-signature container identification | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; implementations enforce immutable-store checks) | diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 1a1d297297..0f088e170c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。纯函数导出 `sniffImageMediaType` 按文件签名识别受支持的图片容器,供接受无扩展名图片路径的消费方使用;持久化调用方仍以存储实现的完整解码为权威。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 @@ -77,6 +77,7 @@ kind: "package-reference" | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | | [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`:规范 base64 强制,随后委托 `saveImages` | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | +| [`src/sniff.ts`](src/sniff.ts) | `sniffImageMediaType`:按文件签名识别图片容器 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;实现负责强制不可变存储检查) | diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 4ee001b86c..89df5e52c6 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -16,6 +16,7 @@ export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedImages } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' +export { sniffImageMediaType } from './sniff.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, diff --git a/packages/attachment/attachment/src/sniff.ts b/packages/attachment/attachment/src/sniff.ts new file mode 100644 index 0000000000..9782d2ffda --- /dev/null +++ b/packages/attachment/attachment/src/sniff.ts @@ -0,0 +1,39 @@ +/** + * Leading-byte image-format identification for consumers that accept image + * files without a media-type-bearing file name, such as normalized attachment + * object paths. The result names the container the signature claims; callers + * that persist bytes keep the attachment service's full decode authoritative. + * @module @deepseek-ai/dsh-attachment/src/sniff + */ + +import type { ImageMediaType } from './types.ts' + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const +const JPEG_SIGNATURE = [0xff, 0xd8, 0xff] as const + +function matchesBytes(data: Uint8Array, offset: number, expected: readonly number[]): boolean { + if (data.byteLength < offset + expected.length) return false + return expected.every((byte, index) => data[offset + index] === byte) +} + +function matchesAscii(data: Uint8Array, offset: number, text: string): boolean { + if (data.byteLength < offset + text.length) return false + for (let index = 0; index < text.length; index += 1) { + if (data[offset + index] !== text.charCodeAt(index)) return false + } + return true +} + +/** + * Identify a supported image container from its file signature. + * @param data - the leading file bytes; passing the complete file is fine. + * @returns the media type the signature claims, or undefined when the bytes + * carry no complete PNG/JPEG/WebP/GIF signature. + */ +export function sniffImageMediaType(data: Uint8Array): ImageMediaType | undefined { + if (matchesBytes(data, 0, PNG_SIGNATURE)) return 'image/png' + if (matchesBytes(data, 0, JPEG_SIGNATURE)) return 'image/jpeg' + if (matchesAscii(data, 0, 'GIF87a') || matchesAscii(data, 0, 'GIF89a')) return 'image/gif' + if (matchesAscii(data, 0, 'RIFF') && matchesAscii(data, 8, 'WEBP')) return 'image/webp' + return undefined +} diff --git a/packages/attachment/attachment/tests/sniff.spec.ts b/packages/attachment/attachment/tests/sniff.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e081abd9697521297206244cbb4369d7d0dd600c GIT binary patch literal 1464 zcmbtTU2EGg6y39b#eK6JT-k$-I)x2dNJbt8Ve7}k*n@0cIU2QWB)Lr}`R|kLINsWn ze#DrA9n6_zZZIMG!E6b!p9fLxq zPbz5)n1R8>z!V3wa>J0fLZCJjCP@&;$`il?a5ROMImUqN1`8X5w6F!C>k7g})2s5xp9QS46L*HN$yf^vd|E+*qT)SE|srR!`OL%RN)di>-Zc6jPBZe|zD>tzN{B;8})begtN;N0^;=<{WuRUCzD4Db3>+ZAfaH!kNf zSnUGKe61cgNY(1(2JdW;AeJiHpzkQe{*&cWu^^&Nbd_FKP!WR&ns lO7{soj&#W1d+5ffOjGurC3gh-l$>ABj;DEs!3E5{e*r&i-dX?v literal 0 HcmV?d00001 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index cd2106cbbc..8913a24022 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: 193f842aa76b7d3f4df717d1d23960ef4d3ee329 -README.zh.md: 3cbd6bec6d9a4b200d90c15b32291cacc2e41aa8 +README.md: c4c8f9270a7b9422d52e6f89567c0d7eeab1f5c6 +README.zh.md: 8bd62f68010e6363515b39e7e71d6361f746713b diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 193f842aa7..c4c8f9270a 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -44,7 +44,7 @@ The policy plugin is optional: without it the tools run against the bare provide | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer; `offset` is 1-based and `limit` defaults to and caps at the configured `readLimit` | -| `read_image` | `file_path` | Reads and persists a PNG/JPEG/WebP/GIF source; normalization can downscale it before the next model request, so the model need not create a thumbnail first | +| `read_image` | `file_path` | Reads and persists a PNG/JPEG/WebP/GIF source; an extension-less path (normalized attachment object paths included) is identified from its file signature; normalization can downscale it before the next model request, so the model need not create a thumbnail first | | `write` | `file_path`, `content` | Creates or fully replaces a file; with the policy plugin, overwriting requires a prior `read` at the unchanged version, creating does not | | `edit` | `file_path`, `old_string`, `new_string`, `replace_all?` | Literal replacement requiring a unique match unless `replace_all` is true; with the policy plugin, requires a prior `read` and an unchanged file | @@ -219,7 +219,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 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. +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 "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `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` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). 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 @@ -237,7 +237,7 @@ Append-only; newly visible content follows the reusable request prefix and does These limits define when the tool suite is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling `dsh-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`. +- **`read` handles UTF-8 text files only** — images use the separate `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 another available tool when it has a filesystem path; a pasted or dragged image without a path cannot be re-read at higher resolution. diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 3cbd6bec6d..8bd62f6801 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -44,7 +44,7 @@ kind: "package-reference" | 工具 | 参数 | 行为 | |---|---|---| | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容与分页 footer;`offset` 从 1 开始,`limit` 默认为配置的 `readLimit`,上限也为该值 | -| `read_image` | `file_path` | 读取并持久保存 PNG/JPEG/WebP/GIF 源图;规范化可在下一次模型请求前缩小图片,因此模型无需先创建缩略图 | +| `read_image` | `file_path` | 读取并持久保存 PNG/JPEG/WebP/GIF 源图;无扩展名路径(包括规范化附件对象路径)按文件签名识别格式;规范化可在下一次模型请求前缩小图片,因此模型无需先创建缩略图 | | `write` | `file_path`、`content` | 创建或完整替换文件;有策略插件时,覆盖要求先在未变版本上执行 `read`,创建不需要 | | `edit` | `file_path`、`old_string`、`new_string`、`replace_all?` | 字面量替换,除非 `replace_all` 为 true 否则要求唯一匹配;有策略插件时,要求先执行 `read` 且文件未变 | @@ -219,7 +219,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 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` 则使用带防护的创建。 +失败会规范化为 `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 "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`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`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。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 影响 @@ -237,7 +237,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 这些限制说明工具套件何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 `dsh-tool-fs-search` 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 -- **`read` 只处理 UTF-8 文本文件**:图像使用独立的、按扩展名路由的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 +- **`read` 只处理 UTF-8 文本文件**:图像使用独立的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 - **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。 - **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。 - **没有附件区域工具**:agent 在拥有文件系统路径时可以通过其他可用工具裁剪图片;没有路径的粘贴或拖入图片无法按更高分辨率重新读取。 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index b1cbad9bb9..5941d85777 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -1,5 +1,8 @@ /** - * The model-facing `read_image` tool commits a PNG/JPEG/WebP/GIF file. + * The model-facing `read_image` tool commits a PNG/JPEG/WebP/GIF file. A path + * without a file extension — normalized attachment objects are named by their + * content digest alone — is accepted too: its format comes from the file + * signature, and the attachment service's full decode stays authoritative. * * 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 @@ -10,8 +13,8 @@ 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 { AttachmentError, AttachmentId, sniffImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, 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' @@ -98,6 +101,13 @@ export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, } } +/** Refuse a media type outside the deployment's accepted set, naming the offending path. */ +function assertDeploymentAccepts(attachments: AttachmentStore, mediaType: ImageMediaType, displayPath: string): void { + if (!attachments.imageLimits.mediaTypes.includes(mediaType)) { + throw new Error(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`) + } +} + /** * Re-brand a structured image outcome into the durable attachment reference an * `ImageBlock` carries. @@ -170,6 +180,7 @@ 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. ' + + 'A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. ' + '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: { @@ -192,19 +203,20 @@ export function applyReadImageTool(ctx: Context): void { async execute(args, exec) { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') - // Every gate runs before any filesystem I/O so a refusal never leaks - // partial reads or attachment writes. - const mediaType = imageMediaTypeForPath(args.file_path) - if (mediaType === undefined) { - throw new Error(`cannot read "${args.file_path}": read_image only accepts PNG/JPEG/WebP/GIF paths`) + // Every pre-read gate runs before any filesystem I/O so a refusal never + // leaks partial reads or attachment writes. An extension-less path + // declares no format, so only its format and deployment media-type + // checks wait for the bytes. + const extension = extname(args.file_path).toLowerCase() + const declared = imageMediaTypeForPath(args.file_path) + if (declared === undefined && extension !== '') { + throw new Error(`cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`) } const attachments = ctx.get('attachments') if (attachments === undefined) { throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) } - if (!attachments.imageLimits.mediaTypes.includes(mediaType)) { - throw new Error(`cannot read "${args.file_path}": ${mediaType} images are not accepted by this deployment`) - } + if (declared !== undefined) assertDeploymentAccepts(attachments, declared, args.file_path) await assertImageCapableRoute(ctx, exec, args.file_path) const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path) @@ -213,6 +225,11 @@ export function applyReadImageTool(ctx: Context): void { // aggregate bound applies beside the per-image bound. const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes) const data = await ctx.fs.readBytes(target, exec.signal, byteCap) + const mediaType = declared ?? sniffImageMediaType(data) + if (mediaType === undefined) { + throw new Error(`cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`) + } + if (declared === undefined) assertDeploymentAccepts(attachments, mediaType, target.displayPath) // Persist before returning: the image block must reference a durably // committed object by the time the tool/result event is appended. let ref: ImageAttachmentRef @@ -247,8 +264,19 @@ export function applyReadImageTool(ctx: Context): void { { cause: error }, ) } + if (error.code === 'INVALID_IMAGE') { + throw new Error( + `cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, + { cause: error }, + ) + } if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error - const extension = extname(target.displayPath).toLowerCase() + if (declared === undefined) { + throw new Error( + `cannot read "${target.displayPath}": the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, + { cause: error }, + ) + } throw new Error( `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, 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`, { cause: error }, diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 7244444245..b2aeb09256 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -1,8 +1,9 @@ /** * The `read_image` tool over the REAL local filesystem and attachment store: - * extension routing, the strict image-modality gate (every refusal arm), - * durable commit + image-block rendering, attachment admission failures, and - * the regression that `read` keeps its text-only contract. + * extension routing, extension-less content sniffing (attachment object paths + * included), the strict image-modality gate (every refusal arm), durable + * commit + image-block rendering, attachment admission failures, and the + * regression that `read` keeps its text-only contract. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -34,6 +35,10 @@ import { const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64') /** 3x3 red PNG used to trip a tiny configured pixel limit. */ const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64') +/** 1x1 red JPEG already inside every normalization limit (byte-identical passthrough). */ +const JPEG_1X1 = Buffer.from('/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgj/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABykX//Z', 'base64') +/** 1x1 red WebP already inside every normalization limit (byte-identical passthrough). */ +const WEBP_1X1 = Buffer.from('UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=', 'base64') const testToolSignal = new AbortController().signal @@ -254,6 +259,175 @@ describe('read_image happy path', () => { }) }) +/** The mounted attachment service, asserted present for direct store calls. */ +function mountedStore(ctx: Context): AttachmentStore { + const attachments = ctx.get('attachments') + if (attachments === undefined) throw new Error('expected the attachment service') + return attachments +} + +/** The host object path behind a reference, asserted present for the local store. */ +function objectPathOf(attachments: AttachmentStore, ref: ImageAttachmentRef): string { + const hostPath = attachments.imageHostPath(ref) + if (hostPath === undefined) throw new Error('expected a host-file-backed store') + return hostPath +} + +describe('extension-less paths', () => { + it('reads a normalized attachment object path directly and dedups to the stored reference', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const first = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(first.isError).toBe(false) + const ref = (first.content[1] as { attachment: ImageAttachmentRef }).attachment + const attachments = mountedStore(ctx) + const objectPath = objectPathOf(attachments, ref) + + const second = await readImage(ctx, { file_path: objectPath }, agentOn('vision-model')) + expect(second.isError).toBe(false) + const reread = (second.content[1] as { attachment: ImageAttachmentRef }).attachment + expect(reread.attachmentId).toBe(ref.attachmentId) + expect(reread.mediaType).toBe('image/png') + expect(text(second)).toContain(`${objectPath}`) + }) + + it.each([ + ['image/jpeg', JPEG_1X1], + ['image/webp', WEBP_1X1], + ] as const)('dedups a re-read %s object to its stored reference', async (mediaType, bytes) => { + const ctx = await setup() + const attachments = mountedStore(ctx) + const ref = await attachments.saveImage({ data: bytes, mediaType, name: 'source' }) + const result = await readImage(ctx, { file_path: objectPathOf(attachments, ref) }, agentOn('vision-model')) + expect(result.isError).toBe(false) + const reread = (result.content[1] as { attachment: ImageAttachmentRef }).attachment + expect(reread.attachmentId).toBe(ref.attachmentId) + expect(reread.mediaType).toBe(mediaType) + }) + + it('forwards an attachment object path read through the outer run_code context', async () => { + const ctx = await setup({ toolMode: 'ptc' }) + const attachments = mountedStore(ctx) + const ref = await attachments.saveImage({ data: PNG_1X1, mediaType: 'image/png', name: 'red.png' }) + const objectPath = objectPathOf(attachments, ref) + const runtime = ctx.codeRuntime as FakeRuntime + runtime.behavior = async (request) => { + const value = await request.bindings[0]!.functions.read_image!({ file_path: objectPath }) + return { logs: [], value } + } + + const result = await call(ctx, RUN_CODE_NAME, { + code: 'return await tools.read_image({ file_path: attachmentPath })', + description: 'Read the attachment object through PTC mode', + }, agentOn('vision-model')) + + expect(result.isError).toBe(false) + const forwarded = result.additionalContexts?.[0]?.content + expect(forwarded?.[1]).toMatchObject({ + type: 'image', + attachment: { attachmentId: ref.attachmentId, mediaType: 'image/png' }, + }) + }) + + it('reads an ordinary extension-less image file by sniffing its content', async () => { + await writeFile(join(dir, 'avatar'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'avatar' }, agentOn('vision-model')) + expect(result.isError).toBe(false) + const image = result.content[1] as { attachment: ImageAttachmentRef } + expect(image.attachment.mediaType).toBe('image/png') + expect(image.attachment.name).toBe('avatar') + }) + + it('refuses extension-less bytes that are not a supported image', async () => { + await writeFile(join(dir, 'notes'), 'plain text, not an image') + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'notes' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain(`cannot read "${join(dir, 'notes')}": the file content is not a supported image format`) + }) + + it('explains extension-less bytes that sniff as an image but do not decode', async () => { + await writeFile(join(dir, 'broken'), PNG_1X1.subarray(0, 16)) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'broken' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image') + }) + + it('reports a missing attachment object through the fs vocabulary', async () => { + const ctx = await setup() + const bogus = join(home, 'attachments', 'v1', 'objects', 'ab', 'ab'.repeat(32)) + const result = await readImage(ctx, { file_path: bogus }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('not found') + }) + + it('applies the deployment media-type policy to the sniffed format', async () => { + /** Store whose deployment accepts JPEG only; sniffed PNG bytes must refuse before any save. */ + class JpegOnlySniffStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/jpeg'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + throw new Error('unreachable: the sniffed-format policy refuses before validation') + } + + saveImage(_input: SaveImageAttachment): Promise { + throw new Error('unreachable: the sniffed-format policy refuses before save') + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'avatar'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(JpegOnlySniffStore) + const result = await readImage(ctx, { file_path: 'avatar' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('image/png images are not accepted by this deployment') + }) + + it('names a signature/decoded-format disagreement on an extension-less path', async () => { + /** Store whose admission reports a media-type mismatch; the tool cannot blame an extension. */ + class MismatchStore 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() + } + + saveImage(_input: SaveImageAttachment): Promise { + throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'sniffed'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(MismatchStore) + const result = await readImage(ctx, { file_path: 'sniffed' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('the file signature claims image/png, but the bytes decode as a different image format') + }) +}) + describe('strict image-modality gate', () => { it('accepts an exact visual route even when the advisory model catalog omits it', async () => { await writeFile(join(dir, 'red.png'), PNG_1X1) @@ -309,7 +483,7 @@ describe('argument and service preconditions', () => { const nonImage = await readImage(ctx, { file_path: 'notes.txt' }, agentOn('vision-model')) expect(nonImage.isError).toBe(true) - expect(text(nonImage)).toContain('only accepts PNG/JPEG/WebP/GIF paths') + expect(text(nonImage)).toContain('the .txt extension does not declare a supported image format') }) it('refuses when no attachment service is mounted', async () => { @@ -373,6 +547,14 @@ describe('image admission failures', () => { expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') }) + it('explains a named image file whose bytes do not decode', async () => { + await writeFile(join(dir, 'broken.png'), PNG_1X1.subarray(0, 16)) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'broken.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain(`cannot read "${join(dir, 'broken.png')}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image`) + }) + it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => { await writeFile(join(dir, 'red.png'), PNG_1X1) const ctx = await setup({ storeConfig: { maxImageBytes: PNG_1X1.length - 1 } }) diff --git a/snapshots/sdk/bash-tool/tool-schemas.expected.json b/snapshots/sdk/bash-tool/tool-schemas.expected.json index e8fd1b5981..e8b70b6c13 100644 --- a/snapshots/sdk/bash-tool/tool-schemas.expected.json +++ b/snapshots/sdk/bash-tool/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json index a3c5b2e531..b3dee1a21b 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json index a3c5b2e531..b3dee1a21b 100644 --- a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json index b3a1813e8b..a95ea12f74 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json index fe0882fe53..5b0728889d 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json index 3d92e885eb..3000f6cb46 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.expected.json @@ -323,7 +323,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json index a3c5b2e531..b3dee1a21b 100644 --- a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json index a3c5b2e531..b3dee1a21b 100644 --- a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/sdk/text-turn/tool-schemas.expected.json b/snapshots/sdk/text-turn/tool-schemas.expected.json index e8fd1b5981..e8b70b6c13 100644 --- a/snapshots/sdk/text-turn/tool-schemas.expected.json +++ b/snapshots/sdk/text-turn/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/agent-instructions/tool-schemas.expected.json b/snapshots/session/agent-instructions/tool-schemas.expected.json index 36df7a0481..ebdc1383f5 100644 --- a/snapshots/session/agent-instructions/tool-schemas.expected.json +++ b/snapshots/session/agent-instructions/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { @@ -1007,7 +1007,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index 8511e0eff3..4ac177a40e 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -154,7 +154,7 @@ 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. 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 a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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; diff --git a/snapshots/session/both-mode-turn/tool-schemas.expected.json b/snapshots/session/both-mode-turn/tool-schemas.expected.json index 9893105262..c0426f9673 100644 --- a/snapshots/session/both-mode-turn/tool-schemas.expected.json +++ b/snapshots/session/both-mode-turn/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/compaction-recovery/tool-schemas.expected.json b/snapshots/session/compaction-recovery/tool-schemas.expected.json index 36df7a0481..ebdc1383f5 100644 --- a/snapshots/session/compaction-recovery/tool-schemas.expected.json +++ b/snapshots/session/compaction-recovery/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { @@ -1007,7 +1007,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index 93ad87e990..556669a52a 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -321,7 +321,7 @@ 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. 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 a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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; diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index 3a0073f42a..bf0d4e5f04 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -503,7 +503,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json index 47769041bd..8a61071acd 100644 --- a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json +++ b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json @@ -226,7 +226,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/lsp-definition/tool-schemas.expected.json b/snapshots/session/lsp-definition/tool-schemas.expected.json index 05543b4012..6c691f70fc 100644 --- a/snapshots/session/lsp-definition/tool-schemas.expected.json +++ b/snapshots/session/lsp-definition/tool-schemas.expected.json @@ -343,7 +343,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/product-subagent-both/tool-schemas.expected.json b/snapshots/session/product-subagent-both/tool-schemas.expected.json index e457070e01..22dc3e2126 100644 --- a/snapshots/session/product-subagent-both/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-both/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/product-subagent-codex/tool-schemas.expected.json b/snapshots/session/product-subagent-codex/tool-schemas.expected.json index bc41f13b88..69094cb8ae 100644 --- a/snapshots/session/product-subagent-codex/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-codex/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json index 2072fb3d15..29836f8c17 100644 --- a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/ptc-read-image-attachment-path/replay.override.json b/snapshots/session/ptc-read-image-attachment-path/replay.override.json new file mode 100644 index 0000000000..c1b0ca939f --- /dev/null +++ b/snapshots/session/ptc-read-image-attachment-path/replay.override.json @@ -0,0 +1,29 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "code-image-source", "name": "run_code", "arguments": "{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}" } }, + { "type": "usage", "usage": { "inputTokens": 3, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "code-image-object", "name": "run_code", "arguments": "{\"code\":\"return await tools.read_image({ file_path: '{{fromRequest:(.+)/red\\.png}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}" } }, + { "type": "usage", "usage": { "inputTokens": 3, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "DONE" } }, + { "type": "usage", "usage": { "inputTokens": 3, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/snapshots/session/ptc-read-image-attachment-path/session.jsonl b/snapshots/session/ptc-read-image-attachment-path/session.jsonl new file mode 100644 index 0000000000..c03535c239 --- /dev/null +++ b/snapshots/session/ptc-read-image-attachment-path/session.jsonl @@ -0,0 +1,48 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1783954000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using run_code, call read_image on red.png, then call read_image again with the extension-less normalized attachment object path it produced, and reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"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":"Using run_code, call read_image on red.png, then call read_image again with the extension-less normalized attachment object path it produced, and reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"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":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Using run_code, call read_image on","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}}}} +{"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":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,15]],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}} +{"type":"tool/code-dispatch-start","data":{"rootCallId":"code-image-source","parentCallId":"code-image-source","subCallId":"code-image-source:code:1","name":"read_image","arguments":{"file_path":"red.png"}}} +{"type":"tool/code-dispatch","data":{"rootCallId":"code-image-source","parentCallId":"code-image-source","subCallId":"code-image-source:code:1","name":"read_image","arguments":{"file_path":"red.png"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}]}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"code-image-source"},"content":[{"type":"tool-result","toolCallId":"code-image-source","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/red.png\",\n \"image\": {\n \"attachmentId\": \"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\",\n \"mediaType\": \"image/png\",\n \"bytes\": 69,\n \"width\": 1,\n \"height\": 1,\n \"name\": \"red.png\"\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"{{message:5}}"}]}} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"user/message","data":{"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}}}} +{"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":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[26,29]],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}} +{"type":"tool/code-dispatch-start","data":{"rootCallId":"code-image-object","parentCallId":"code-image-object","subCallId":"code-image-object:code:1","name":"read_image","arguments":{"file_path":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}} +{"type":"tool/code-dispatch","data":{"rootCallId":"code-image-object","parentCallId":"code-image-object","subCallId":"code-image-object:code:1","name":"read_image","arguments":{"file_path":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}]}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"code-image-object"},"content":[{"type":"tool-result","toolCallId":"code-image-object","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\",\n \"image\": {\n \"attachmentId\": \"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\",\n \"mediaType\": \"image/png\",\n \"bytes\": 69,\n \"width\": 1,\n \"height\": 1,\n \"name\": \"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"{{message:8}}"}]}} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"user/message","data":{"content":[{"type":"text","text":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:9}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[40,43]],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/ptc-read-image-attachment-path/snapshot.yml b/snapshots/session/ptc-read-image-attachment-path/snapshot.yml new file mode 100644 index 0000000000..dc929c458a --- /dev/null +++ b/snapshots/session/ptc-read-image-attachment-path/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: ptc-read-image-attachment-path +profile: headless +composition: ptc-image +recording: authored +header: + class: ptc-image +replay: + override: true +platform: posix diff --git a/snapshots/session/ptc-read-image-attachment-path/workspace/red.png b/snapshots/session/ptc-read-image-attachment-path/workspace/red.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/snapshots/session/ptc-read-image/system-prompt.expected.md b/snapshots/session/ptc-read-image/system-prompt.expected.md index 42106b19ac..7b20dd24e2 100644 --- a/snapshots/session/ptc-read-image/system-prompt.expected.md +++ b/snapshots/session/ptc-read-image/system-prompt.expected.md @@ -156,7 +156,7 @@ 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. 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 a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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; diff --git a/snapshots/session/ptc-turn/system-prompt.expected.md b/snapshots/session/ptc-turn/system-prompt.expected.md index ed2959eb01..1b5d70f27d 100644 --- a/snapshots/session/ptc-turn/system-prompt.expected.md +++ b/snapshots/session/ptc-turn/system-prompt.expected.md @@ -156,7 +156,7 @@ 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. 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 a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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; diff --git a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json index 144d2309e9..41b07174f1 100644 --- a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json +++ b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/ralph-loop/tool-schemas.1.expected.json b/snapshots/session/ralph-loop/tool-schemas.1.expected.json index dbcc5636e5..adf4451608 100644 --- a/snapshots/session/ralph-loop/tool-schemas.1.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.1.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/ralph-loop/tool-schemas.2.expected.json b/snapshots/session/ralph-loop/tool-schemas.2.expected.json index dbcc5636e5..adf4451608 100644 --- a/snapshots/session/ralph-loop/tool-schemas.2.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.2.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/read-image-attachment-path/replay.override.json b/snapshots/session/read-image-attachment-path/replay.override.json new file mode 100644 index 0000000000..513c2b63eb --- /dev/null +++ b/snapshots/session/read-image-attachment-path/replay.override.json @@ -0,0 +1,29 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "read-image-source", "name": "read_image", "arguments": "{\"file_path\":\"red.png\"}" } }, + { "type": "usage", "usage": { "inputTokens": 3, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "read-image-object", "name": "read_image", "arguments": "{\"file_path\":\"{{fromRequest:(.+)/red\\.png}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}" } }, + { "type": "usage", "usage": { "inputTokens": 3, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "DONE" } }, + { "type": "usage", "usage": { "inputTokens": 3, "outputTokens": 3 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/snapshots/session/read-image-attachment-path/session.jsonl b/snapshots/session/read-image-attachment-path/session.jsonl new file mode 100644 index 0000000000..4ea43a90dc --- /dev/null +++ b/snapshots/session/read-image-attachment-path/session.jsonl @@ -0,0 +1,38 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1783953000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then call read_image again with the extension-less normalized attachment object path under .dsh, and reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"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 to look at red.png in the current directory, then call read_image again with the extension-less normalized attachment object path under .dsh, and reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"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":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Use read_image to look at","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} +{"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":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,15]],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-source"},"content":[{"type":"tool-result","toolCallId":"read-image-source","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[17],"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":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}}}} +{"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":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[21,24]],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"read-image-object"},"content":[{"type":"tool-result","toolCallId":"read-image-object","content":[{"type":"text","text":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[30,33]],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/read-image-attachment-path/snapshot.yml b/snapshots/session/read-image-attachment-path/snapshot.yml new file mode 100644 index 0000000000..724699b789 --- /dev/null +++ b/snapshots/session/read-image-attachment-path/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: read-image-attachment-path +profile: headless +composition: image +recording: authored +header: + class: image +replay: + override: true diff --git a/snapshots/session/read-image-attachment-path/workspace/red.png b/snapshots/session/read-image-attachment-path/workspace/red.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/snapshots/session/session-query-spill/tool-schemas.expected.json b/snapshots/session/session-query-spill/tool-schemas.expected.json index 3e28d4cfcb..929cd0fa8d 100644 --- a/snapshots/session/session-query-spill/tool-schemas.expected.json +++ b/snapshots/session/session-query-spill/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json index 796bac1779..ab4af3db64 100644 --- a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json index 7e4dfe696c..4b6b5b10f4 100644 --- a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json +++ b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json @@ -369,7 +369,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/text-turn/tool-schemas.expected.json b/snapshots/session/text-turn/tool-schemas.expected.json index c93fb6d65f..df890396ac 100644 --- a/snapshots/session/text-turn/tool-schemas.expected.json +++ b/snapshots/session/text-turn/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/session/web-fetch/tool-schemas.expected.json b/snapshots/session/web-fetch/tool-schemas.expected.json index ee18836349..5381c3bc24 100644 --- a/snapshots/session/web-fetch/tool-schemas.expected.json +++ b/snapshots/session/web-fetch/tool-schemas.expected.json @@ -306,7 +306,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/web/cordis-tool-round/tool-schemas.expected.json b/snapshots/web/cordis-tool-round/tool-schemas.expected.json index b558e1d094..4b21fbb5b5 100644 --- a/snapshots/web/cordis-tool-round/tool-schemas.expected.json +++ b/snapshots/web/cordis-tool-round/tool-schemas.expected.json @@ -566,7 +566,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index 8232bc9e23..c07e7ea3f3 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/snapshots/web/fresh-round-trip/tool-schemas.expected.json @@ -369,7 +369,7 @@ }, { "name": "read_image", - "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.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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": { diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 6cda9dc20d..b74423d3c7 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -184,7 +184,7 @@ 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. 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 a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. 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; From 4096b0b408ed6d1380facbfdb34c9f498c58eab0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 15:02:12 +0800 Subject: [PATCH 039/429] refactor(time-context): scan turn messages in reverse --- packages/context/time-context/src/index.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 6649655709..87f09e2241 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -77,15 +77,15 @@ function formatDuration(elapsedMs: number): string { /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { - const events = agent.session.snapshotEvents() - const start = events.findLastIndex( - event => event.type === 'turn/start' && event.data.turn === turn, - ) - const entered = start < 0 - ? [] - : events.slice(start + 1) - .flatMap(event => event.type === 'user/message' ? [event.data] : []) - return [...entered, ...proposed] + const entered: UserMessage[] = [] + for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { + const event = agent.session.eventAt(seq) + if (event?.type === 'turn/start' && event.data.turn === turn) { + return [...entered.reverse(), ...proposed] + } + if (event?.type === 'user/message') entered.push(event.data) + } + return [...proposed] } function renderText( From 21d039be1b95305cca9e4de10d0b8fd7eac69623 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 15:03:47 +0800 Subject: [PATCH 040/429] fix(web): label the Turn-time TTFT row as first-token latency, not an average The Turn fold publishes the first step's TTFT (contract/turn-metrics.ts firstStepTtftMs), never an average; only the Session StatsLine averages across steps. Rename the dialog row in both locales and align the Agent Note with what actually ships. --- .../feature/2026-08-28-web-turn-stat-pills.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-28-web-turn-stat-pills.md | 2 +- .../implemented/feature/2026-08-28-web-turn-stat-pills.zh.md | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 2 +- packages/client/ui-chat/src/client/locale.ts | 4 ++-- packages/client/ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../client/ui-chat/tests/turn-usage-panel.client.spec.tsx | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml index a044b62071..8085d4a54a 100644 --- a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.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-28-web-turn-stat-pills.md -2026-08-28-web-turn-stat-pills.md: cfba7db77507f88bdf1f03b80d62751830e5122d -2026-08-28-web-turn-stat-pills.zh.md: 05e5f193f246b3245c2ce169547950326fb7557f +2026-08-28-web-turn-stat-pills.md: 2661c228b9a5a7b55ba3698cf3ee2b4c76701bbc +2026-08-28-web-turn-stat-pills.zh.md: 5cc889b9f743eefeac1343b70743c23392efc908 diff --git a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md index cfba7db775..2661c228b9 100644 --- a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md +++ b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md @@ -10,7 +10,7 @@ A completed assistant Turn ended with two stacked footer rows: a `Turn usage` Di ## Decision -The tail keeps one `MessageIconActions` row. Two stat pills sit right of the branch action: a database pill labelled with the compact Turn total (`Usage 15.8K tok`) and a clock pill labelled with the wall time (`Ran for 19s`); the message clock stays plain text at the row end. Each pill is an `aria-haspopup="dialog"` trigger that portals a fixed-position dialog to `document.body`, placed above the trigger by `useAnchoredPosition` with a 12px viewport clamp and closed by outside pointerdown or Escape (ContextMeter's pattern). The usage dialog holds the exact total, provider/model routes, cache-hit rate, token buckets, and the reasoning subset inline in Output; the time dialog holds total run time, decode TPS, and average TTFT. Facts absent from the fold render no row, and a window without publishable Turn usage renders no usage pill; the token-meter fold and `turn/start` gating are unchanged from [exact per-Turn usage](2026-08-24-web-per-turn-token-usage.md). +The tail keeps one `MessageIconActions` row. Two stat pills sit right of the branch action: a database pill labelled with the compact Turn total (`Usage 15.8K tok`) and a clock pill labelled with the wall time (`Ran for 19s`); the message clock stays plain text at the row end. Each pill is an `aria-haspopup="dialog"` trigger that portals a fixed-position dialog to `document.body`, placed above the trigger by `useAnchoredPosition` with a 12px viewport clamp and closed by outside pointerdown or Escape (ContextMeter's pattern). The usage dialog holds the exact total, provider/model routes, cache-hit rate, token buckets, and the reasoning subset inline in Output; the time dialog holds total run time, decode TPS, and the Turn's first-token latency (the first step's TTFT). Facts absent from the fold render no row, and a window without publishable Turn usage renders no usage pill; the token-meter fold and `turn/start` gating are unchanged from [exact per-Turn usage](2026-08-24-web-per-turn-token-usage.md). Row visibility follows recency: turn tails and user rows tag `data-actions-reveal`, the latest of each kind stays `always` visible, earlier rows reveal on hover or focus-within under `@media (hover: hover)`, and no-hover devices keep every row visible. Below 480px the pill labels hide and each pill takes the sibling action-button geometry — 28px width, 6px padding, centered glyph, and no adjacent-pill margin rebate — so the bare icons keep the row's 8px rhythm. diff --git a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md index 05e5f193f2..5cc889b9f7 100644 --- a/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md @@ -10,7 +10,7 @@ ## 决定 -尾部只保留一行 `MessageIconActions`。分叉操作右侧放两个统计 pill:数据库图标 pill 标注紧凑的本轮总量(`用量 15.8K tok`),时钟图标 pill 标注墙钟用时(`用时 19秒`);消息时钟保持纯文字置于行尾。每个 pill 是 `aria-haspopup="dialog"` 触发器,把固定定位的弹层 portal 到 `document.body`,由 `useAnchoredPosition` 锚定在触发器上方并保持 12px 视口边距,外部 pointerdown 或 Escape 关闭(沿用 ContextMeter 模式)。用量弹层承载精确总量、提供方/模型路由、缓存命中率、token 分桶及输出内联的推理子集;用时弹层承载本轮总用时、解码 TPS、首 token 平均用时。fold 未产出的事实不渲染行,窗口内无可发布的 Turn 用量则不渲染用量 pill;token-meter fold 与 `turn/start` 门控沿用[精确 per-Turn 用量](2026-08-24-web-per-turn-token-usage.zh.md),未做改动。 +尾部只保留一行 `MessageIconActions`。分叉操作右侧放两个统计 pill:数据库图标 pill 标注紧凑的本轮总量(`用量 15.8K tok`),时钟图标 pill 标注墙钟用时(`用时 19秒`);消息时钟保持纯文字置于行尾。每个 pill 是 `aria-haspopup="dialog"` 触发器,把固定定位的弹层 portal 到 `document.body`,由 `useAnchoredPosition` 锚定在触发器上方并保持 12px 视口边距,外部 pointerdown 或 Escape 关闭(沿用 ContextMeter 模式)。用量弹层承载精确总量、提供方/模型路由、缓存命中率、token 分桶及输出内联的推理子集;用时弹层承载本轮总用时、解码 TPS、本轮首 token 用时(取首个 step 的 TTFT)。fold 未产出的事实不渲染行,窗口内无可发布的 Turn 用量则不渲染用量 pill;token-meter fold 与 `turn/start` 门控沿用[精确 per-Turn 用量](2026-08-24-web-per-turn-token-usage.zh.md),未做改动。 行可见性按新近度门控:turn 尾行与用户行标记 `data-actions-reveal`,各自最新一行保持 `always` 常显,更早的行在 `@media (hover: hover)` 下 hover 或 focus-within 才显示,无 hover 设备恒显示。480px 以下 pill 隐藏文字并取同排操作按钮的几何——28px 宽、6px 内边距、图标居中、取消相邻 pill 的边距补偿——让裸图标保持行的 8px 节奏。 diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 40f5f0e017..5b5520f93d 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -198,7 +198,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' }) expect(await timeDialog.count()).toBe(1) expect(await timeDialog.getByText(/tok\/s/).count()).toBe(1) - expect(await timeDialog.getByText('Average time to first token (TTFT)', { exact: true }).count()).toBe(1) + expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1) await page.keyboard.press('Escape') await trigger.click() diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 82a3329d05..cd0f6f50ce 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -102,7 +102,7 @@ export const zh = { 'message.turnTime.title': '本轮用时和速度', 'message.turnTime.duration': '本轮总用时', 'message.turnTime.speed': '输出速度(TPS)', - 'message.turnTime.ttft': '首 token 平均用时(TTFT)', + 'message.turnTime.ttft': '首 token 用时(TTFT)', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', @@ -218,7 +218,7 @@ export const en = { 'message.turnTime.title': 'Turn time and speed', 'message.turnTime.duration': 'Total run time', 'message.turnTime.speed': 'Tokens per second (TPS)', - 'message.turnTime.ttft': 'Average time to first token (TTFT)', + 'message.turnTime.ttft': 'Time to first token (TTFT)', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'command.running': 'Running…', 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 6b72d28236..bc3022cc3d 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1583,7 +1583,7 @@ describe('ChatView', () => { expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度') expect(timeDialog.textContent).toContain('本轮总用时19秒') expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s') - expect(timeDialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒') + expect(timeDialog.textContent).toContain('首 token 用时(TTFT)1.2秒') }) it('withholds the usage-details trigger when turn usage is outside the window', () => { diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx index c9ef6d45f1..cd1704eb61 100644 --- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx @@ -116,7 +116,7 @@ describe('TurnTimePanel', () => { const details = dialog.querySelector('[data-turn-time-details]') as HTMLElement expect(details.textContent).toContain('Total run time19s') expect(details.textContent).toContain('Tokens per second (TPS)20 tok/s') - expect(details.textContent).toContain('Average time to first token (TTFT)1.2s') + expect(details.textContent).toContain('Time to first token (TTFT)1.2s') fireEvent.keyDown(document, { key: 'Escape' }) expect(view.queryByRole('dialog')).toBeNull() From aaa692033b8c98cc69701eb52ff6600eaaea167e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 28 Aug 2026 15:16:20 +0800 Subject: [PATCH 041/429] test(snapshots): project new attachment-path fixtures into packed layout --- .../session/ptc-read-image-attachment-path/session.jsonl | 6 +++--- snapshots/session/read-image-attachment-path/session.jsonl | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/snapshots/session/ptc-read-image-attachment-path/session.jsonl b/snapshots/session/ptc-read-image-attachment-path/session.jsonl index c03535c239..09da819ab9 100644 --- a/snapshots/session/ptc-read-image-attachment-path/session.jsonl +++ b/snapshots/session/ptc-read-image-attachment-path/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}}}} {"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":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,15]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"code-image-source","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: 'red.png' })\",\"description\":\"Read the source image\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"code-image-source","parentCallId":"code-image-source","subCallId":"code-image-source:code:1","name":"read_image","arguments":{"file_path":"red.png"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"code-image-source","parentCallId":"code-image-source","subCallId":"code-image-source:code:1","name":"read_image","arguments":{"file_path":"red.png"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}]}} @@ -29,7 +29,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}}}} {"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":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[26,29]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"code-image-object","name":"run_code","arguments":"{\"code\":\"return await tools.read_image({ file_path: '{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' })\",\"description\":\"Read the normalized attachment object directly\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"code-image-object","parentCallId":"code-image-object","subCallId":"code-image-object:code:1","name":"read_image","arguments":{"file_path":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"code-image-object","parentCallId":"code-image-object","subCallId":"code-image-object:code:1","name":"read_image","arguments":{"file_path":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}]}} @@ -43,6 +43,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:9}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[40,43]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:9}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/read-image-attachment-path/session.jsonl b/snapshots/session/read-image-attachment-path/session.jsonl index 4ea43a90dc..aabb815515 100644 --- a/snapshots/session/read-image-attachment-path/session.jsonl +++ b/snapshots/session/read-image-attachment-path/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} {"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":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,15]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-source","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-source"},"content":[{"type":"tool-result","toolCallId":"read-image-source","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -24,7 +24,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}}}} {"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":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[21,24]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[21,22,23,24],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"read-image-object","name":"read_image","arguments":"{\"file_path\":\"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\"}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"read-image-object"},"content":[{"type":"tool-result","toolCallId":"read-image-object","content":[{"type":"text","text":"{{cwd}}/.dsh/attachments/v1/objects/b1/b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640"}}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} @@ -33,6 +33,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[30,33]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 6f08798981f64ed631794f1fd3c47808a5c167c9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 28 Aug 2026 15:37:27 +0800 Subject: [PATCH 042/429] docs(tool-fs): sharpen extension notes and pin dotfile edge cases --- .../2026-08-10-minimal-read-image-tool.i18n.yaml | 4 ++-- .../feature/2026-08-10-minimal-read-image-tool.md | 2 +- .../2026-08-10-minimal-read-image-tool.zh.md | 2 +- ...-image-extensionless-attachment-paths.i18n.yaml | 4 ++-- ...28-read-image-extensionless-attachment-paths.md | 2 +- ...read-image-extensionless-attachment-paths.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 ++-- packages/fs/tool-fs/README.md | 3 ++- packages/fs/tool-fs/README.zh.md | 3 ++- packages/fs/tool-fs/tests/read-image.spec.ts | 14 ++++++++++++++ 10 files changed, 28 insertions(+), 12 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 4c276c6b0d..2853ef84aa 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: 8880032b2648846df679ea8fa3301d182a95c06b -2026-08-10-minimal-read-image-tool.zh.md: aec34e19fc58037b031f7d4116d2fa664b2b45b5 +2026-08-10-minimal-read-image-tool.md: 0bf3e3ca79316f15727db1708b5f68ccb3d2d368 +2026-08-10-minimal-read-image-tool.zh.md: 130849991491a39200d8e2dda0e4dc9c1f166fb6 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 8880032b26..0bf3e3ca79 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 @@ -22,7 +22,7 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged - **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. +- **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. This rejection covers extension-bearing paths; [extension-less attachment object paths](2026-08-28-read-image-extensionless-attachment-paths.md) narrows it — a path that declares nothing is identified from its file signature. - **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 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 aec34e19fc..1308499914 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 @@ -22,7 +22,7 @@ Status: implemented - **PR #598 的路由作用域设计**使用 request-ready 扩展点、按路由控制 schema 可见性、可逆投影和三个持久概念。共享 LLM 请求投影现在可以处理纯文本路由,无需把工具注册或会话格式放进 agent-loop。 - **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。 -- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。 +- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。这一拒绝覆盖带扩展名的路径;[无扩展名附件对象路径](2026-08-28-read-image-extensionless-attachment-paths.zh.md)将其收窄,什么也没声明的路径按文件签名识别。 - **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml index 02d0e3310e..a861a2e49e 100644 --- a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.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-28-read-image-extensionless-attachment-paths.md -2026-08-28-read-image-extensionless-attachment-paths.md: be6acb2ca336a976bcbf0c50efda0b933fb06df6 -2026-08-28-read-image-extensionless-attachment-paths.zh.md: a4e1e677a46dd72661944e09101994358c829877 +2026-08-28-read-image-extensionless-attachment-paths.md: 0ca5a65ced68062612cff5494af811624188a7fb +2026-08-28-read-image-extensionless-attachment-paths.zh.md: 547c62cd9adb631744e5e79306f390bd17c8932d diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md index be6acb2ca3..0ca5a65ced 100644 --- a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md +++ b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md @@ -10,7 +10,7 @@ Model-visible image descriptors name the normalized attachment's local read-only ## Decision -The extension stays the declared media type when it names one of the four supported formats, and every existing gate and diagnostic on that branch is unchanged. A path without an extension is no longer refused up front: the tool reads the bytes under the existing byte cap and identifies the container with `sniffImageMediaType`, a pure file-signature helper exported by the attachment Service Definition package. The sniffed type then passes through the same deployment media-type policy and `saveImage` admission, so the store's full decode remains the authority; a path whose extension names a non-image format is still refused before any I/O. +The extension stays the declared media type when it names one of the four supported formats, and every existing gate and diagnostic on that branch is unchanged. For a path without an extension the tool reads the bytes under the existing byte cap and identifies the container with `sniffImageMediaType`, a pure file-signature helper exported by the attachment Service Definition package. The sniffed type then passes through the same deployment media-type policy and `saveImage` admission, so the store's full decode remains the authority; a path whose extension names a non-image format is refused before any I/O. This narrows the sniffing rejection in [the minimal read_image tool note](2026-08-10-minimal-read-image-tool.md) to extension-bearing paths: an extension is still a declaration that fails closed on mismatch, while a path that declares nothing is identified from its bytes. Reading an object path re-saves its bytes rather than short-circuiting through a reverse path lookup. Normalization passes an already-normalized image through byte-identically, so the re-save deduplicates to the same content-addressed reference; a store test pins that idempotency for a re-encoded object. No reference proof is required at the tool: object paths are unguessable content digests, the published objects are already readable through the mounted filesystem (Bash included), and the session-reference gate continues to protect the remote client RPC, which is the boundary where an attachment id alone grants bytes. diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md index a4e1e677a4..547c62cd9a 100644 --- a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决定 -扩展名命名四种受支持格式之一时仍作为声明的媒体类型,该分支的所有既有检查和诊断保持不变。无扩展名的路径不再被提前拒绝:工具在既有字节上限内读取字节,用 `sniffImageMediaType` 识别容器格式,这是附件 Service Definition 包导出的纯文件签名辅助函数。识别出的类型随后经过同一套部署媒体类型政策和 `saveImage` 准入,存储实现的完整解码仍是权威;扩展名声明了非图片格式的路径依旧在任何 I/O 之前被拒绝。 +扩展名命名四种受支持格式之一时仍作为声明的媒体类型,该分支的所有既有检查和诊断保持不变。对无扩展名的路径,工具在既有字节上限内读取字节,用 `sniffImageMediaType` 识别容器格式,这是附件 Service Definition 包导出的纯文件签名辅助函数。识别出的类型随后经过同一套部署媒体类型政策和 `saveImage` 准入,存储实现的完整解码仍是权威;扩展名声明了非图片格式的路径在任何 I/O 之前被拒绝。这把[最小 read_image 工具笔记](2026-08-10-minimal-read-image-tool.zh.md)中对嗅探的拒绝收窄到带扩展名的路径:扩展名仍是不匹配即失败的声明,而什么也没声明的路径按其字节识别。 读取对象路径会重新保存其字节,而不是通过反向路径查找走捷径。归一化让已归一化的图片字节原样通过,因此重新保存会去重到同一个内容寻址引用;一个存储测试固定了重编码对象的这一幂等性。工具处不需要会话引用证明:对象路径是不可猜测的内容摘要,已发布对象本来就能通过挂载的文件系统(包括 Bash)读到,而会话引用检查继续保护远程客户端 RPC,那才是仅凭附件 id 就能获得字节的边界。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 8913a24022..b2de0de1b6 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: c4c8f9270a7b9422d52e6f89567c0d7eeab1f5c6 -README.zh.md: 8bd62f68010e6363515b39e7e71d6361f746713b +README.md: 31e7b4c900cbd6cb232dac229eb93abde88b6042 +README.zh.md: c962f0028d56eb52e577828bf7b3e56a90b4e9d1 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index c4c8f9270a..31e7b4c900 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -238,7 +238,8 @@ These limits define when the tool suite is a poor fit or needs special operation - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling `dsh-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 `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. +- **Extension-declared media type** — an 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. Only a path with no extension is identified from its file signature. +- **Object paths re-enter source admission** — `read_image` on a normalized attachment object re-admits its bytes as a new source, so a deployment whose `maxImageBytes`/`maxMessageImageBytes` sit below the normalized-image byte budget can refuse an object path that `ctx.attachments.readImage` still serves; shipped defaults keep the normalized budget (4 MiB) far under the source caps (20 MiB). - **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 another available tool when it has a filesystem path; a pasted or dragged image without a path cannot be re-read at higher resolution. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no timeout budget; cancellation rides `exec.signal` only ([provider rationale](../README.md)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 8bd62f6801..c962f0028d 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -238,7 +238,8 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 `dsh-tool-fs-search` 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 - **`read` 只处理 UTF-8 文本文件**:图像使用独立的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 -- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。 +- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。只有没有扩展名的路径按文件签名识别格式。 +- **对象路径重新走源准入**:对规范化附件对象调用 `read_image` 会把其字节作为新来源重新准入,因此把 `maxImageBytes`/`maxMessageImageBytes` 配置得低于规范化图片字节预算的部署可能拒绝 `ctx.attachments.readImage` 仍可读取的对象路径;默认配置下规范化预算(4 MiB)远低于源上限(20 MiB)。 - **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。 - **没有附件区域工具**:agent 在拥有文件系统路径时可以通过其他可用工具裁剪图片;没有路径的粘贴或拖入图片无法按更高分辨率重新读取。 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明超时预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.zh.md))。 diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index b2aeb09256..f0f0802198 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -339,6 +339,20 @@ describe('extension-less paths', () => { expect(image.attachment.name).toBe('avatar') }) + it('pins the trailing-dot refusal and reads a dotfile through sniffing', async () => { + await writeFile(join(dir, '.hidden'), PNG_1X1) + const ctx = await setup() + const trailingDot = await readImage(ctx, { file_path: 'foo.' }, agentOn('vision-model')) + expect(trailingDot.isError).toBe(true) + expect(text(trailingDot)).toContain('cannot read "foo.": the . extension does not declare a supported image format') + + const dotfile = await readImage(ctx, { file_path: '.hidden' }, agentOn('vision-model')) + expect(dotfile.isError).toBe(false) + const image = dotfile.content[1] as { attachment: ImageAttachmentRef } + expect(image.attachment.mediaType).toBe('image/png') + expect(image.attachment.name).toBe('.hidden') + }) + it('refuses extension-less bytes that are not a supported image', async () => { await writeFile(join(dir, 'notes'), 'plain text, not an image') const ctx = await setup() From f3c69c2c3feffe4c033489b2dce08315051514d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 28 Aug 2026 16:10:19 +0800 Subject: [PATCH 043/429] refactor(tool-fs): keep image sniffing tool-local --- ...-read-image-extensionless-paths.i18n.yaml} | 6 +- ...26-08-28-read-image-extensionless-paths.md | 29 ++++++ ...08-28-read-image-extensionless-paths.zh.md | 29 ++++++ ...26-08-10-minimal-read-image-tool.i18n.yaml | 4 +- .../2026-08-10-minimal-read-image-tool.md | 2 +- .../2026-08-10-minimal-read-image-tool.zh.md | 2 +- ...ad-image-extensionless-attachment-paths.md | 27 ------ ...image-extensionless-attachment-paths.zh.md | 27 ------ docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment-local/tests/store.spec.ts | 22 ----- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 3 +- packages/attachment/attachment/README.zh.md | 3 +- packages/attachment/attachment/src/index.ts | 1 - packages/attachment/attachment/src/sniff.ts | 39 -------- .../attachment/attachment/tests/sniff.spec.ts | Bin 1464 -> 0 bytes packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 + packages/fs/tool-fs/README.zh.md | 2 + packages/fs/tool-fs/src/read-image.ts | 39 +++++++- packages/fs/tool-fs/tests/read-image.spec.ts | 89 ++++++------------ 23 files changed, 141 insertions(+), 201 deletions(-) rename .agents/notes/implemented/{feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml => bug-fix/2026-08-28-read-image-extensionless-paths.i18n.yaml} (53%) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md delete mode 100644 .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md delete mode 100644 packages/attachment/attachment/src/sniff.ts delete mode 100644 packages/attachment/attachment/tests/sniff.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.i18n.yaml similarity index 53% rename from .agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.i18n.yaml index a861a2e49e..0f9fa28a2a 100644 --- a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.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-28-read-image-extensionless-attachment-paths.md -2026-08-28-read-image-extensionless-attachment-paths.md: 0ca5a65ced68062612cff5494af811624188a7fb -2026-08-28-read-image-extensionless-attachment-paths.zh.md: 547c62cd9adb631744e5e79306f390bd17c8932d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.md +2026-08-28-read-image-extensionless-paths.md: cf7d82b2ed86208bcb8ef6287dfcfc7c4391ccc4 +2026-08-28-read-image-extensionless-paths.zh.md: 8d4cd15f6c414bb28307863bfe61ea3a7181ed02 diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.md b/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.md new file mode 100644 index 0000000000..cf7d82b2ed --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.md @@ -0,0 +1,29 @@ +# Agent Note: read_image accepts extension-less image paths + +Status: implemented + +English | [中文](2026-08-28-read-image-extensionless-paths.zh.md) + +## Problem + +`read_image` mapped `file_path` to a media type by extension alone and refused a path with no extension. Valid extension-less images therefore required a renamed copy before the model could inspect them. Normalized local attachment objects exposed to the model use content digests without extensions, so their published read-only paths triggered the same refusal. + +## Decision + +`read_image` treats a file extension as a media-type declaration. PNG, JPEG, WebP, and GIF extensions select their declared types; another non-empty extension is refused before filesystem I/O, and the attachment store's full decode rejects a declaration that does not match the bytes. A path with no extension is read through `ctx.fs` under the existing `maxImageBytes` and tighter `maxMessageImageBytes` cap, then a tool-local `sniffImageMediaType` helper identifies one of the four supported file signatures. The detected type passes through the same deployment media-type policy and `saveImage` admission, whose full decode remains authoritative. This narrows the sniffing rejection in [the minimal read_image tool note](../feature/2026-08-10-minimal-read-image-tool.md) to extension-bearing paths. + +The mounted `ctx.fs` backend is the complete path-authorization authority for `read_image`. Extensions and file signatures decide only whether the tool accepts bytes that the backend returned. Any valid extension-less image readable through that backend can enter the current session, including a normalized attachment object; the tool performs no session-reference proof and the attachment service exposes no reverse path lookup. + +Admission failures name the offending path. An extension-less mismatch names the signature that supplied the declaration, while unsupported bytes report no file content. + +## Alternatives considered + +**Export signature identification from the attachment Service Definition package.** Only `read_image` needs this pre-admission declaration. Publishing the helper would make one Consumer's filename policy part of the provider-independent attachment API while the store already owns authoritative decoding. + +**Special-case normalized attachment object paths.** Resolving a path back to a Session reference would make two files readable through the same `ctx.fs` behave differently according to their origin and would leave ordinary extension-less images unsupported. Filesystem access remains the read authorization decision. + +**Add extensions to stored attachment objects.** This would change the storage layout and every object-path consumer to satisfy one tool's media-type declaration rule. + +## Consequences + +The model can read ordinary extension-less images and normalized attachment paths directly in native and PTC modes. Wrong extensions retain their pre-I/O refusal and mismatch repair. A non-image path without an extension is read up to the image byte cap before rejection, and a normalized object re-enters source admission instead of bypassing the current deployment limits. The behavior changes only `dsh-tool-fs`; the attachment Service Definition and local provider keep their existing APIs and storage behavior. diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.zh.md b/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.zh.md new file mode 100644 index 0000000000..8d4cd15f6c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-read-image-extensionless-paths.zh.md @@ -0,0 +1,29 @@ +# Agent Note: read_image 接受无扩展名图片路径 + +Status: implemented + +[English](2026-08-28-read-image-extensionless-paths.md) | 中文 + +## 问题 + +`read_image` 只按扩展名把 `file_path` 映射到媒体类型,并拒绝没有扩展名的路径。因此,模型必须先创建一份改名副本,才能查看合法的无扩展名图片。向模型公开的规范化本地附件对象以内容摘要命名,不带扩展名,所以其已发布的只读路径也会触发同一项拒绝。 + +## 决定 + +`read_image` 把文件扩展名视为媒体类型声明。PNG、JPEG、WebP 与 GIF 扩展名选择各自声明的类型;其他非空扩展名在文件系统 I/O 前被拒绝,附件存储的完整解码会拒绝与字节不匹配的声明。对于无扩展名路径,工具通过 `ctx.fs` 在既有 `maxImageBytes` 和更严格的 `maxMessageImageBytes` 上限内读取文件,再由工具内部的 `sniffImageMediaType` 辅助函数识别四种受支持的文件签名。识别结果经过同一套部署媒体类型策略和 `saveImage` 准入,后者的完整解码保持权威。这把[最小 read_image 工具 Agent Note](../feature/2026-08-10-minimal-read-image-tool.zh.md)中对嗅探的拒绝收窄到带扩展名的路径。 + +挂载的 `ctx.fs` 后端是 `read_image` 路径授权的完整依据。扩展名和文件签名只决定工具是否接受后端返回的字节。该后端可读的每个合法无扩展名图片都能进入当前会话,包括规范化附件对象;工具不证明 Session 引用,附件服务也不提供反向路径查找。 + +准入失败会指出出错路径。无扩展名路径的类型不匹配会指出提供声明的文件签名,而不受支持的字节不会出现在错误消息中。 + +## 考虑过的替代方案 + +**从附件 Service Definition 包导出文件签名识别。** 只有 `read_image` 需要这项准入前声明。公开该辅助函数会把单个消费方的文件名策略加入提供方无关的附件 API,而存储已经负责权威解码。 + +**特殊处理规范化附件对象路径。** 把路径反查为 Session 引用,会使 `ctx.fs` 以相同方式提供的两个文件根据来源产生不同读取结果,而且普通无扩展名图片仍然不受支持。文件系统访问保持读取授权决定。 + +**为存储的附件对象增加扩展名。** 这会为了满足一个工具的媒体类型声明规则而修改存储布局和每个对象路径消费方。 + +## 影响 + +模型可以在 native 和 PTC 模式下直接读取普通无扩展名图片与规范化附件路径。错误扩展名保留 I/O 前拒绝和类型不匹配修复提示。无扩展名非图片路径会在拒绝前读取到图片字节上限,规范化对象也会重新经过来源准入,而不会绕过当前部署限额。行为改动只位于 `dsh-tool-fs`;附件 Service Definition 与本地提供方保持现有 API 和存储行为。 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 2853ef84aa..d57485473f 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: 0bf3e3ca79316f15727db1708b5f68ccb3d2d368 -2026-08-10-minimal-read-image-tool.zh.md: 130849991491a39200d8e2dda0e4dc9c1f166fb6 +2026-08-10-minimal-read-image-tool.md: be7c24965ee256e4062b5caf32ce7f8c62d9c1ae +2026-08-10-minimal-read-image-tool.zh.md: f1d09a320e0f44145e4c8681668996d66fbb4898 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 0bf3e3ca79..be7c24965e 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 @@ -22,7 +22,7 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged - **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. This rejection covers extension-bearing paths; [extension-less attachment object paths](2026-08-28-read-image-extensionless-attachment-paths.md) narrows it — a path that declares nothing is identified from its file signature. +- **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. This rejection covers extension-bearing paths; [extension-less image paths](../bug-fix/2026-08-28-read-image-extensionless-paths.md) narrows it — a path that declares nothing is identified from its file signature. - **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 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 1308499914..f1d09a320e 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 @@ -22,7 +22,7 @@ Status: implemented - **PR #598 的路由作用域设计**使用 request-ready 扩展点、按路由控制 schema 可见性、可逆投影和三个持久概念。共享 LLM 请求投影现在可以处理纯文本路由,无需把工具注册或会话格式放进 agent-loop。 - **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。 -- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。这一拒绝覆盖带扩展名的路径;[无扩展名附件对象路径](2026-08-28-read-image-extensionless-attachment-paths.zh.md)将其收窄,什么也没声明的路径按文件签名识别。 +- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。这一拒绝覆盖带扩展名的路径;[无扩展名图片路径](../bug-fix/2026-08-28-read-image-extensionless-paths.zh.md)将其收窄,什么也没声明的路径按文件签名识别。 - **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md deleted file mode 100644 index 0ca5a65ced..0000000000 --- a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: read_image accepts extension-less attachment object paths - -Status: implemented - -English | [中文](2026-08-28-read-image-extensionless-attachment-paths.zh.md) - -## Problem - -Model-visible image descriptors name the normalized attachment's local read-only path, and normalized objects are content-addressed files without an image extension. `read_image` mapped `file_path` to a media type by extension alone and refused everything else, so passing the descriptor's own path back produced `read_image only accepts PNG/JPEG/WebP/GIF paths` for an image the store had already validated and persisted. The model's only workarounds were copying the object to a renamed file or re-uploading it. - -## Decision - -The extension stays the declared media type when it names one of the four supported formats, and every existing gate and diagnostic on that branch is unchanged. For a path without an extension the tool reads the bytes under the existing byte cap and identifies the container with `sniffImageMediaType`, a pure file-signature helper exported by the attachment Service Definition package. The sniffed type then passes through the same deployment media-type policy and `saveImage` admission, so the store's full decode remains the authority; a path whose extension names a non-image format is refused before any I/O. This narrows the sniffing rejection in [the minimal read_image tool note](2026-08-10-minimal-read-image-tool.md) to extension-bearing paths: an extension is still a declaration that fails closed on mismatch, while a path that declares nothing is identified from its bytes. - -Reading an object path re-saves its bytes rather than short-circuiting through a reverse path lookup. Normalization passes an already-normalized image through byte-identically, so the re-save deduplicates to the same content-addressed reference; a store test pins that idempotency for a re-encoded object. No reference proof is required at the tool: object paths are unguessable content digests, the published objects are already readable through the mounted filesystem (Bash included), and the session-reference gate continues to protect the remote client RPC, which is the boundary where an attachment id alone grants bytes. - -Two diagnostics are sharpened alongside: `INVALID_IMAGE` admission failures now name the offending path instead of surfacing the store's bare message, and an extension-less admission mismatch blames the file signature rather than a nonexistent extension. - -## Consequences - -The model reads a descriptor's normalized attachment path directly, in native and PTC modes, without copying, renaming, or re-uploading; recorded scenarios `read-image-attachment-path` and `ptc-read-image-attachment-path` pin both flows end to end, including deduplication to the stored reference. Ordinary extension-less image files become readable through the same content identification, while a wrong-extension path keeps its fast pre-I/O refusal and repair message. Any future consumer accepting extension-less image paths can reuse `sniffImageMediaType` instead of a second signature table. - -## Alternatives considered - -**Name objects with an extension on disk.** This changes the storage layout, dedup commit path, corrupt checks, export archive, and every committed object-path expectation, only to satisfy the tool's extension heuristic, and the heuristic itself — extension as media-type authority — stays wrong. The store already treats decoded content as the authority. - -**Instruct the model to copy the object before reading.** Prompt guidance does not stop direct calls, so the misleading refusal survives; the copy adds a filesystem write and a turn for a pure read; and the re-read commits the identical bytes to the same reference anyway, so the copy changes nothing but cost. diff --git a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md b/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md deleted file mode 100644 index 547c62cd9a..0000000000 --- a/.agents/notes/implemented/feature/2026-08-28-read-image-extensionless-attachment-paths.zh.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agent Note: read_image 接受无扩展名的附件对象路径 - -Status: implemented - -[English](2026-08-28-read-image-extensionless-attachment-paths.md) | 中文 - -## 问题 - -模型可见的图片描述会给出规范化附件的本地只读路径,而规范化对象是不带图片扩展名的内容寻址文件。`read_image` 仅凭扩展名把 `file_path` 映射到媒体类型并拒绝其余一切,于是把描述里的路径原样传回会得到 `read_image only accepts PNG/JPEG/WebP/GIF paths`,尽管该图片已经通过存储校验并持久保存。模型只能把对象复制成改名文件或要求重新上传。 - -## 决定 - -扩展名命名四种受支持格式之一时仍作为声明的媒体类型,该分支的所有既有检查和诊断保持不变。对无扩展名的路径,工具在既有字节上限内读取字节,用 `sniffImageMediaType` 识别容器格式,这是附件 Service Definition 包导出的纯文件签名辅助函数。识别出的类型随后经过同一套部署媒体类型政策和 `saveImage` 准入,存储实现的完整解码仍是权威;扩展名声明了非图片格式的路径在任何 I/O 之前被拒绝。这把[最小 read_image 工具笔记](2026-08-10-minimal-read-image-tool.zh.md)中对嗅探的拒绝收窄到带扩展名的路径:扩展名仍是不匹配即失败的声明,而什么也没声明的路径按其字节识别。 - -读取对象路径会重新保存其字节,而不是通过反向路径查找走捷径。归一化让已归一化的图片字节原样通过,因此重新保存会去重到同一个内容寻址引用;一个存储测试固定了重编码对象的这一幂等性。工具处不需要会话引用证明:对象路径是不可猜测的内容摘要,已发布对象本来就能通过挂载的文件系统(包括 Bash)读到,而会话引用检查继续保护远程客户端 RPC,那才是仅凭附件 id 就能获得字节的边界。 - -顺带收紧了两处诊断:`INVALID_IMAGE` 准入失败现在会指出出错的路径而不是透传存储的裸消息,无扩展名路径的准入不匹配归因于文件签名而不是不存在的扩展名。 - -## 影响 - -模型在 native 和 PTC 两种模式下都可以直接读取描述中的规范化附件路径,无需复制、改名或重新上传;录制场景 `read-image-attachment-path` 和 `ptc-read-image-attachment-path` 端到端固定了这两条流程,包括去重到已存储引用。普通的无扩展名图片文件也通过同一套内容识别变得可读,而扩展名错误的路径保留 I/O 之前的快速拒绝和修复消息。将来任何接受无扩展名图片路径的消费方都可以复用 `sniffImageMediaType`,不必再维护一张签名表。 - -## 考虑过的替代方案 - -**在磁盘上给对象命名加扩展名。** 这会改动存储布局、去重提交路径、损坏检查、导出归档以及每一处已提交的对象路径期望,只为迁就工具的扩展名启发式,而启发式本身(以扩展名为媒体类型权威)依然是错的。存储实现一直以解码内容为权威。 - -**指示模型先复制对象再读取。** 提示词挡不住直接调用,误导性的拒绝依然存在;复制为一次纯读取平添一次文件系统写入和一轮往返;重新读取本来就会把相同字节提交到同一引用,复制除了成本什么也不改变。 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 9f3d160b0a..8e69eaae28 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: e93e6782eae97811be2060807a867d3036931a8c -attachment.zh.md: a0167cec6d1ddd8a06bdd7501edcb3d1c6ababe5 +attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 +attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index e93e6782ea..15daa2b8d5 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. The model may pass such a path back to `read_image` directly: extension-less files are identified from their file signature, and re-saving a normalized object deduplicates to the same content-addressed reference. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index a0167cec6d..c74a18f6be 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。模型可以把这样的路径直接传回 `read_image`:无扩展名文件按文件签名识别格式,重新保存规范化对象会去重到同一个内容寻址引用。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index e1c5a788d6..3ff58eb2da 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -172,28 +172,6 @@ describe('local attachment store', () => { expect(String(saved.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) }) - it('re-saves a re-encoded normalized object byte-identically to the same reference', 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 policy = { maxPixels: POLICY.maxPixels, maxDimension: 2, maxBytes: 1024 * 1024 } - const saved = await saveImageFile(storageRoot, { - data: oversized, mediaType: 'image/png', name: 'big.png', - }, { ...LIMITS, maxImagePixels: 64 }, policy) - - // Normalization passes an already-normalized object through unchanged, so - // reading the object file and saving those bytes again deduplicates: the - // read_image tool can accept object paths without minting new attachments. - const objectBytes = (await readImageFile(storageRoot, saved)).data - const resaved = await saveImageFile(storageRoot, { - data: objectBytes, mediaType: saved.mediaType, name: 'big', - }, { ...LIMITS, maxImagePixels: 64 }, policy) - expect(resaved.attachmentId).toBe(saved.attachmentId) - expect(resaved.bytes).toBe(saved.bytes) - expect(resaved.originalDimensions).toBeUndefined() - }) - 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) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index aac868951f..e67d95604d 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: feb8b14a2a46543169820e444994ef7cb1514210 -README.zh.md: 0f088e170cde9fb27032351dc1f9861c9111b6fd +README.md: 01a5d6ee143ff53175dd7327cd6d419af61938a3 +README.zh.md: 1a1d297297a6815ce5338391af0182e471f99000 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index feb8b14a2a..01a5d6ee14 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. The pure `sniffImageMediaType` export identifies a supported container from its file signature for consumers that accept extension-less image paths; persisting callers keep the store's full decode authoritative. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map @@ -77,7 +77,6 @@ The service family runs one admission-and-storage flow: every entry point enforc | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | | [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`: canonical-base64 enforcement, then `saveImages` delegation | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | -| [`src/sniff.ts`](src/sniff.ts) | `sniffImageMediaType`: file-signature container identification | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; implementations enforce immutable-store checks) | diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 0f088e170c..1a1d297297 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。纯函数导出 `sniffImageMediaType` 按文件签名识别受支持的图片容器,供接受无扩展名图片路径的消费方使用;持久化调用方仍以存储实现的完整解码为权威。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 @@ -77,7 +77,6 @@ kind: "package-reference" | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | | [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`:规范 base64 强制,随后委托 `saveImages` | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | -| [`src/sniff.ts`](src/sniff.ts) | `sniffImageMediaType`:按文件签名识别图片容器 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;实现负责强制不可变存储检查) | diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 89df5e52c6..4ee001b86c 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -16,7 +16,6 @@ export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedImages } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' -export { sniffImageMediaType } from './sniff.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, diff --git a/packages/attachment/attachment/src/sniff.ts b/packages/attachment/attachment/src/sniff.ts deleted file mode 100644 index 9782d2ffda..0000000000 --- a/packages/attachment/attachment/src/sniff.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Leading-byte image-format identification for consumers that accept image - * files without a media-type-bearing file name, such as normalized attachment - * object paths. The result names the container the signature claims; callers - * that persist bytes keep the attachment service's full decode authoritative. - * @module @deepseek-ai/dsh-attachment/src/sniff - */ - -import type { ImageMediaType } from './types.ts' - -const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const -const JPEG_SIGNATURE = [0xff, 0xd8, 0xff] as const - -function matchesBytes(data: Uint8Array, offset: number, expected: readonly number[]): boolean { - if (data.byteLength < offset + expected.length) return false - return expected.every((byte, index) => data[offset + index] === byte) -} - -function matchesAscii(data: Uint8Array, offset: number, text: string): boolean { - if (data.byteLength < offset + text.length) return false - for (let index = 0; index < text.length; index += 1) { - if (data[offset + index] !== text.charCodeAt(index)) return false - } - return true -} - -/** - * Identify a supported image container from its file signature. - * @param data - the leading file bytes; passing the complete file is fine. - * @returns the media type the signature claims, or undefined when the bytes - * carry no complete PNG/JPEG/WebP/GIF signature. - */ -export function sniffImageMediaType(data: Uint8Array): ImageMediaType | undefined { - if (matchesBytes(data, 0, PNG_SIGNATURE)) return 'image/png' - if (matchesBytes(data, 0, JPEG_SIGNATURE)) return 'image/jpeg' - if (matchesAscii(data, 0, 'GIF87a') || matchesAscii(data, 0, 'GIF89a')) return 'image/gif' - if (matchesAscii(data, 0, 'RIFF') && matchesAscii(data, 8, 'WEBP')) return 'image/webp' - return undefined -} diff --git a/packages/attachment/attachment/tests/sniff.spec.ts b/packages/attachment/attachment/tests/sniff.spec.ts deleted file mode 100644 index e081abd9697521297206244cbb4369d7d0dd600c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1464 zcmbtTU2EGg6y39b#eK6JT-k$-I)x2dNJbt8Ve7}k*n@0cIU2QWB)Lr}`R|kLINsWn ze#DrA9n6_zZZIMG!E6b!p9fLxq zPbz5)n1R8>z!V3wa>J0fLZCJjCP@&;$`il?a5ROMImUqN1`8X5w6F!C>k7g})2s5xp9QS46L*HN$yf^vd|E+*qT)SE|srR!`OL%RN)di>-Zc6jPBZe|zD>tzN{B;8})begtN;N0^;=<{WuRUCzD4Db3>+ZAfaH!kNf zSnUGKe61cgNY(1(2JdW;AeJiHpzkQe{*&cWu^^&Nbd_FKP!WR&ns lO7{soj&#W1d+5ffOjGurC3gh-l$>ABj;DEs!3E5{e*r&i-dX?v diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index b2de0de1b6..8705144163 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: 31e7b4c900cbd6cb232dac229eb93abde88b6042 -README.zh.md: c962f0028d56eb52e577828bf7b3e56a90b4e9d1 +README.md: f00c96d18619e0fb59609eb66729ebfce445dbc6 +README.zh.md: 5915e5a07de10775469d0ebe301564948b357569 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 31e7b4c900..f00c96d186 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -65,6 +65,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Policy and sandbox behavior +Path authorization for `read` and `read_image` belongs entirely to `ctx.fs`; media-type declarations and file signatures only decide whether `read_image` accepts the bytes returned by that backend. + With the policy plugin mounted, `write` and `edit` obtain their guard from the `fs/*` intent slots, so an unread target or a stale observation fails with `FS_NOT_OBSERVED` or `FS_STALE_VERSION` and a recovery instruction. Under a confining backend (`fs-sandbox`), `write`/`edit` additionally advertise `sandbox_permissions` and `justification`; a denied mutation returns the `[sandbox: file access denied under mode]` marker with the same-turn escalation hint, and an approved retry may stamp a strictly wider mode for that one call. ### Failures and recovery diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index c962f0028d..5915e5a07d 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -65,6 +65,8 @@ kind: "package-reference" ### 策略与沙箱行为 +`read` 与 `read_image` 的路径授权完全由 `ctx.fs` 负责;媒体类型声明和文件签名只决定 `read_image` 是否接受该后端返回的字节。 + 挂载策略插件后,`write` 与 `edit` 从 `fs/*` 意图槽位取得防护,因此未读目标或陈旧观察会以 `FS_NOT_OBSERVED` 或 `FS_STALE_VERSION` 及恢复指令失败。使用施加沙箱限制的后端(`fs-sandbox`)时,`write`/`edit` 还会公开 `sandbox_permissions` 与 `justification`;被拒绝的变更返回 `[sandbox: file access denied under mode]` 标记与同轮次升级提示,获批的重试可以在该次调用中加盖严格更宽的模式。 ### 失败与恢复 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 5941d85777..508d18de7a 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -1,8 +1,8 @@ /** * The model-facing `read_image` tool commits a PNG/JPEG/WebP/GIF file. A path - * without a file extension — normalized attachment objects are named by their - * content digest alone — is accepted too: its format comes from the file - * signature, and the attachment service's full decode stays authoritative. + * without a file extension is identified from its file signature, while the + * attachment service's full decode stays authoritative. The mounted `ctx.fs` + * backend owns path resolution and read access; names only declare media type. * * 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 +13,7 @@ import { basename, extname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' -import { AttachmentError, AttachmentId, sniffImageMediaType } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -30,6 +30,35 @@ const IMAGE_EXTENSIONS: Readonly> = { '.gif': 'image/gif', } +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const +const JPEG_SIGNATURE = [0xff, 0xd8, 0xff] as const + +function matchesBytes(data: Uint8Array, offset: number, expected: readonly number[]): boolean { + if (data.byteLength < offset + expected.length) return false + return expected.every((byte, index) => data[offset + index] === byte) +} + +function matchesAscii(data: Uint8Array, offset: number, value: string): boolean { + if (data.byteLength < offset + value.length) return false + for (let index = 0; index < value.length; index += 1) { + if (data[offset + index] !== value.charCodeAt(index)) return false + } + return true +} + +/** + * Identify the media type declared by a supported image file signature. + * @param data - file bytes read through the current filesystem backend. + * @returns the detected supported media type, or undefined for other bytes. + */ +export function sniffImageMediaType(data: Uint8Array): ImageMediaType | undefined { + if (matchesBytes(data, 0, PNG_SIGNATURE)) return 'image/png' + if (matchesBytes(data, 0, JPEG_SIGNATURE)) return 'image/jpeg' + if (matchesAscii(data, 0, 'GIF87a') || matchesAscii(data, 0, 'GIF89a')) return 'image/gif' + if (matchesAscii(data, 0, 'RIFF') && matchesAscii(data, 8, 'WEBP')) return 'image/webp' + return undefined +} + const IMAGE_VALUE_SCHEMA = { type: 'object', additionalProperties: false, @@ -264,7 +293,7 @@ export function applyReadImageTool(ctx: Context): void { { cause: error }, ) } - if (error.code === 'INVALID_IMAGE') { + if (error.code === 'INVALID_IMAGE' && declared === undefined) { throw new Error( `cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, { cause: error }, diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index f0f0802198..2ac096429e 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -29,16 +29,13 @@ import { formatImageReadOutput, imageMediaTypeForPath, imageRefFromValue, + sniffImageMediaType, } from '../src/read-image.ts' /** 1x1 red PNG (valid signature, IHDR, IDAT). */ const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64') /** 3x3 red PNG used to trip a tiny configured pixel limit. */ const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64') -/** 1x1 red JPEG already inside every normalization limit (byte-identical passthrough). */ -const JPEG_1X1 = Buffer.from('/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgj/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABykX//Z', 'base64') -/** 1x1 red WebP already inside every normalization limit (byte-identical passthrough). */ -const WEBP_1X1 = Buffer.from('UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=', 'base64') const testToolSignal = new AbortController().signal @@ -170,6 +167,30 @@ describe('imageMediaTypeForPath', () => { }) }) +function ascii(value: string): Uint8Array { + return new TextEncoder().encode(value) +} + +describe('sniffImageMediaType', () => { + it('identifies each supported container from its complete signature', () => { + expect(sniffImageMediaType(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]))).toBe('image/png') + expect(sniffImageMediaType(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]))).toBe('image/jpeg') + expect(sniffImageMediaType(ascii('GIF87a...'))).toBe('image/gif') + expect(sniffImageMediaType(ascii('GIF89a...'))).toBe('image/gif') + expect(sniffImageMediaType(ascii('RIFF\0\0\0\0WEBPVP8 '))).toBe('image/webp') + }) + + it('returns undefined for other bytes, incomplete signatures, and non-WebP RIFF containers', () => { + expect(sniffImageMediaType(new Uint8Array())).toBeUndefined() + expect(sniffImageMediaType(ascii('plain text'))).toBeUndefined() + expect(sniffImageMediaType(Uint8Array.from([0x89, 0x50, 0x4e]))).toBeUndefined() + expect(sniffImageMediaType(Uint8Array.from([0xff, 0xd8]))).toBeUndefined() + expect(sniffImageMediaType(ascii('GIF90a'))).toBeUndefined() + expect(sniffImageMediaType(ascii('RIFF\0\0\0\0WAVE'))).toBeUndefined() + expect(sniffImageMediaType(ascii('RIFF\0\0\0'))).toBeUndefined() + }) +}) + describe('imageRefFromValue', () => { it('re-brands with and without the optional display name', () => { const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } @@ -291,44 +312,6 @@ describe('extension-less paths', () => { expect(text(second)).toContain(`${objectPath}`) }) - it.each([ - ['image/jpeg', JPEG_1X1], - ['image/webp', WEBP_1X1], - ] as const)('dedups a re-read %s object to its stored reference', async (mediaType, bytes) => { - const ctx = await setup() - const attachments = mountedStore(ctx) - const ref = await attachments.saveImage({ data: bytes, mediaType, name: 'source' }) - const result = await readImage(ctx, { file_path: objectPathOf(attachments, ref) }, agentOn('vision-model')) - expect(result.isError).toBe(false) - const reread = (result.content[1] as { attachment: ImageAttachmentRef }).attachment - expect(reread.attachmentId).toBe(ref.attachmentId) - expect(reread.mediaType).toBe(mediaType) - }) - - it('forwards an attachment object path read through the outer run_code context', async () => { - const ctx = await setup({ toolMode: 'ptc' }) - const attachments = mountedStore(ctx) - const ref = await attachments.saveImage({ data: PNG_1X1, mediaType: 'image/png', name: 'red.png' }) - const objectPath = objectPathOf(attachments, ref) - const runtime = ctx.codeRuntime as FakeRuntime - runtime.behavior = async (request) => { - const value = await request.bindings[0]!.functions.read_image!({ file_path: objectPath }) - return { logs: [], value } - } - - const result = await call(ctx, RUN_CODE_NAME, { - code: 'return await tools.read_image({ file_path: attachmentPath })', - description: 'Read the attachment object through PTC mode', - }, agentOn('vision-model')) - - expect(result.isError).toBe(false) - const forwarded = result.additionalContexts?.[0]?.content - expect(forwarded?.[1]).toMatchObject({ - type: 'image', - attachment: { attachmentId: ref.attachmentId, mediaType: 'image/png' }, - }) - }) - it('reads an ordinary extension-less image file by sniffing its content', async () => { await writeFile(join(dir, 'avatar'), PNG_1X1) const ctx = await setup() @@ -369,14 +352,6 @@ describe('extension-less paths', () => { expect(text(result)).toContain('do not decode as a supported PNG/JPEG/WebP/GIF image') }) - it('reports a missing attachment object through the fs vocabulary', async () => { - const ctx = await setup() - const bogus = join(home, 'attachments', 'v1', 'objects', 'ab', 'ab'.repeat(32)) - const result = await readImage(ctx, { file_path: bogus }, agentOn('vision-model')) - expect(result.isError).toBe(true) - expect(text(result)).toContain('not found') - }) - it('applies the deployment media-type policy to the sniffed format', async () => { /** Store whose deployment accepts JPEG only; sniffed PNG bytes must refuse before any save. */ class JpegOnlySniffStore extends AttachmentStore { @@ -561,18 +536,10 @@ describe('image admission failures', () => { expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') }) - it('explains a named image file whose bytes do not decode', async () => { - await writeFile(join(dir, 'broken.png'), PNG_1X1.subarray(0, 16)) - const ctx = await setup() - const result = await readImage(ctx, { file_path: 'broken.png' }, agentOn('vision-model')) - expect(result.isError).toBe(true) - expect(text(result)).toContain(`cannot read "${join(dir, 'broken.png')}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image`) - }) - - it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => { - await writeFile(join(dir, 'red.png'), PNG_1X1) + it('caps an extension-less read at maxImageBytes before format detection', async () => { + await writeFile(join(dir, 'red'), PNG_1X1) const ctx = await setup({ storeConfig: { maxImageBytes: PNG_1X1.length - 1 } }) - const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + const result = await readImage(ctx, { file_path: 'red' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('exceeds') }) From 6daed7c5aa7bb74de5d711b477b437171304aaf7 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 16:24:07 +0800 Subject: [PATCH 044/429] fix(client): make trigger-menu Enter an explicit no-op while refinement pends Retained rows made arbitrate('enter') claim 'pick-highlighted' while pick() silently declined the pending group, so the key vanished by coincidence. Check the highlighted group's readiness like Tab does and return 'consumed' deliberately; record the stale-while-revalidate menu decision in an Agent Note. --- ...gger-menu-stale-while-revalidate.i18n.yaml | 6 +++++ ...-28-trigger-menu-stale-while-revalidate.md | 27 +++++++++++++++++++ ...-trigger-menu-stale-while-revalidate.zh.md | 27 +++++++++++++++++++ .../ui-input-trigger/src/client/controller.ts | 5 ++++ .../tests/service.client.spec.ts | 23 ++++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml new file mode 100644 index 0000000000..bc6b1e4a09 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.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-28-trigger-menu-stale-while-revalidate.md +2026-08-28-trigger-menu-stale-while-revalidate.md: 12541ba3ec6ee82ac6c12da85f99c0d8e044b9e8 +2026-08-28-trigger-menu-stale-while-revalidate.zh.md: 69b3d1b6a304562e1bb1835b1de2f09f8f38c5a4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md new file mode 100644 index 0000000000..12541ba3ec --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md @@ -0,0 +1,27 @@ +# Agent Note: The trigger menu keeps previous rows through refinement + +Status: implemented + +English | [中文](2026-08-28-trigger-menu-stale-while-revalidate.zh.md) + +## Problem + +Every keystroke inside an open `@`/`/` trigger menu launches a new candidates fetch. The menu reducer's `hit` case used to reseed the groups to pending-empty, so the list collapsed to a skeleton for the 100–460ms fetch round trip and repainted on every character — a visible flicker on each refinement keystroke (#3234). + +## Decision + +The reducer's `hit` case (`core/menu.ts`) now retains the previous query's rows and highlight, marking each group `pending` — stale-while-revalidate. Fresh opens (`seedGroups`) still start empty, so the first paint keeps its skeleton; `allReadyEmpty` still auto-closes after settle. + +Stale rows are display-only. `pick()` requires the candidate's group to be `ready`, and the `enter` arbitration checks the highlighted group's status before picking: during the pending window Enter is an explicit no-op (`'consumed'`) — it neither picks the stale row nor falls through to submit the draft. Tab already carried the same `ready` check for drilling. + +## Alternatives considered + +**Clear to a skeleton on every refinement.** Rejected; this was the flickering status quo. The production chat frontend's conversation search does clear (results and active index reset per debounced query), which keeps its Enter trivially safe — but its list is in a dedicated dialog, whereas this menu repaints directly under the caret on every keystroke, where the flicker is what users reported. + +**Pass Enter through to submit during the pending window.** Rejected. Before this change the window showed an empty skeleton, so Enter falling through to send was visually consistent; with retained rows the user is looking at a highlighted candidate, and sending the whole draft under it is a worse mis-fire than a few hundred milliseconds of dead key. The production search's pending-window Enter is likewise a no-op. + +**Queue the Enter and pick when the fetch settles.** Rejected. Acting on a keypress against rows the user has not seen yet reintroduces the stale-pick race with extra timing machinery. + +## Consequences + +Refinement keystrokes no longer flicker; the list content swaps in place when the fetch settles. The costs: Enter is dead for the pending window (pressing it again after settle picks normally), and rows are index-keyed, so a settle swaps DOM node content in place — pointer tests must wait for a stale-only row to disappear before clicking (`reference-composer.e2e.ts` polls `folderx/` away). A pre-existing highlight blink during refinement remains open and is deferred to a follow-up. diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md new file mode 100644 index 0000000000..69b3d1b6a3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md @@ -0,0 +1,27 @@ +# Agent Note: The trigger menu keeps previous rows through refinement + +Status: implemented + +[English](2026-08-28-trigger-menu-stale-while-revalidate.md) | 中文 + +## Problem + +在已打开的 `@`/`/` 触发菜单里,每个按键都会发起一次新的候选请求。菜单 reducer 的 `hit` 分支过去会把各组重置为 pending-空,于是列表在 100–460ms 的请求往返期间塌缩成骨架屏,每输入一个字符就重绘一次——细化查询时肉眼可见的闪烁(#3234)。 + +## Decision + +reducer 的 `hit` 分支(`core/menu.ts`)现在保留上一次查询的行和高亮,并把各组标记为 `pending`——即 stale-while-revalidate。首次打开(`seedGroups`)仍从空开始,首帧保持骨架屏;`allReadyEmpty` 仍在结算后自动关闭。 + +旧行仅用于显示。`pick()` 要求候选所在组为 `ready`,`enter` 仲裁在 pick 前检查高亮组的状态:pending 窗口内 Enter 是显式 no-op(`'consumed'`)——既不选中旧行,也不落到草稿发送。Tab 的下钻早已带有相同的 `ready` 检查。 + +## Alternatives considered + +**每次细化都清空为骨架屏。** 拒绝;这正是闪烁的现状。线上 chat 前端的会话搜索确实是清空(每次防抖查询重置结果和活动索引),其 Enter 因此天然安全——但那个列表在独立弹窗里,而本菜单直接在光标下随每个按键重绘,闪烁正是用户所报告的问题。 + +**pending 窗口内让 Enter 透传到发送。** 拒绝。改动前该窗口显示空骨架屏,Enter 落到发送在视觉上是自洽的;保留旧行后用户正看着一个高亮候选,此时把整条草稿发出去比几百毫秒的按键失效是更糟的误触。线上搜索在 pending 窗口的 Enter 同样是 no-op。 + +**把 Enter 排队,请求结算后再选中。** 拒绝。对用户尚未见到的行执行按键会重新引入选中旧数据的竞态,还额外增加时序机制。 + +## Consequences + +细化按键不再闪烁;请求结算时列表内容原位替换。代价:pending 窗口内 Enter 失效(结算后再按即正常选中);行按 index 作为 key,结算时 DOM 节点内容原位替换——指针类测试点击前必须等待仅旧查询匹配的行消失(`reference-composer.e2e.ts` 轮询 `folderx/` 消失)。细化期间已存在的高亮闪动问题仍未解决,留待后续 PR。 diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 5d3e1bf0b3..883a795775 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -236,6 +236,11 @@ export class InputTriggerController { } case 'enter': { if (state.highlight === null) return 'pass' + // Refinement keeps the previous rows and highlight visible while the + // next fetch is pending; Enter then neither picks the stale row nor + // falls through to submit — an explicit no-op until the group is ready. + const group = state.groups.find(g => g.source === state.highlight?.source) + if (group === undefined || group.status !== 'ready') return 'consumed' this.pick(state.highlight.source, state.highlight.index) return 'pick-highlighted' } 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 43eeba0f64..929d1e81e9 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -903,6 +903,29 @@ describe('arbitrate', () => { controller.track('/g', 2, { tier: 'plain' }, 1) expect(controller.arbitrate('enter', false)).toBe('pass') }) + + it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => { + const picks: string[] = [] + const cmd = deferredSource('/', 'command', { + onPick: (pick) => { picks.push(pick.candidate.name); return undefined }, + }) + const { controller } = controllerBench([cmd.source]) + controller.track('/g', 2, { tier: 'plain' }, 1) + cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }]) + await tick() + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + // Refinement: previous rows and highlight stay visible while the fetch pends. + controller.track('/go', 3, { tier: 'plain' }, 2) + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + expect(controller.arbitrate('enter', false)).toBe('consumed') + expect(picks).toHaveLength(0) + expect(controller.menu.getSnapshot().open).toBe(true) + // Settled: the same gesture picks again. + cmd.pending[1]!.resolve([{ name: 'goal' }]) + await tick() + expect(controller.arbitrate('enter', false)).toBe('pick-highlighted') + expect(picks).toEqual(['goal']) + }) }) describe('onSpace', () => { From 51a6eabb2796209d2975ea08145ed76336a71598 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 28 Aug 2026 17:36:55 +0800 Subject: [PATCH 045/429] perf(api): replace shift-backed stream queues --- ...-08-28-linear-stream-queue-drain.i18n.yaml | 6 + .../2026-08-28-linear-stream-queue-drain.md | 56 ++++++++++ ...2026-08-28-linear-stream-queue-drain.zh.md | 56 ++++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 3 +- docs/config-catalog.zh.md | 3 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + packages/api/gateway/package.json | 1 + .../api/gateway/src/client/stream-client.ts | 11 +- packages/api/gateway/src/index.ts | 7 +- packages/api/gateway/tsconfig.client.json | 3 + packages/api/gateway/tsconfig.host.json | 3 + packages/api/remotes/package.json | 1 + packages/api/remotes/src/index.ts | 11 +- packages/api/remotes/tsconfig.host.json | 3 + packages/api/session-controller/package.json | 1 + .../api/session-controller/src/control.ts | 9 +- .../api/session-controller/src/history.ts | 11 +- .../api/session-controller/tsconfig.host.json | 1 + .../api/workspace-controller/package.json | 1 + packages/api/workspace-controller/src/feed.ts | 9 +- .../workspace-controller/tsconfig.host.json | 1 + packages/client/tsdown.client.ts | 2 +- packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 5 +- packages/util/README.zh.md | 5 +- packages/util/deque/README.i18n.yaml | 6 + packages/util/deque/README.md | 104 ++++++++++++++++++ packages/util/deque/README.zh.md | 104 ++++++++++++++++++ packages/util/deque/benchmarks/drain.ts | 36 ++++++ packages/util/deque/package.json | 42 +++++++ packages/util/deque/src/index.ts | 95 ++++++++++++++++ packages/util/deque/src/invariant.ts | 30 +++++ packages/util/deque/tests/deque.spec.ts | 70 ++++++++++++ packages/util/deque/tests/invariant.spec.ts | 18 +++ packages/util/deque/tsconfig.json | 15 +++ pnpm-lock.yaml | 21 ++++ scripts/client-bundle-purity.spec.ts | 3 +- scripts/doc-standard.spec.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.host.json | 1 + 44 files changed, 737 insertions(+), 39 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.zh.md create mode 100644 packages/util/deque/README.i18n.yaml create mode 100644 packages/util/deque/README.md create mode 100644 packages/util/deque/README.zh.md create mode 100644 packages/util/deque/benchmarks/drain.ts create mode 100644 packages/util/deque/package.json create mode 100644 packages/util/deque/src/index.ts create mode 100644 packages/util/deque/src/invariant.ts create mode 100644 packages/util/deque/tests/deque.spec.ts create mode 100644 packages/util/deque/tests/invariant.spec.ts create mode 100644 packages/util/deque/tsconfig.json diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.i18n.yaml new file mode 100644 index 0000000000..db7441ec9f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.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-28-linear-stream-queue-drain.md +2026-08-28-linear-stream-queue-drain.md: e92dd03ef52ead44b083f997f83746cee03af97f +2026-08-28-linear-stream-queue-drain.zh.md: 1d0ff4813b8a7e61e196cbdc63bab0c501d49e11 diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.md b/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.md new file mode 100644 index 0000000000..e92dd03ef5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.md @@ -0,0 +1,56 @@ +# Agent Note: Linear drain for long-lived stream queues + +Status: implemented + +English | [中文](2026-08-28-linear-stream-queue-drain.zh.md) + +## Problem + +Long-lived stream queues can accumulate thousands of frames while their consumers are busy. Removing each frame with `Array.prototype.shift()` moves the remaining array range on the observed V8 path, so draining `N` queued frames performs quadratic reference movement and delays unrelated work on the same event loop. [Issue #3270](https://github.com/deepseek-harness/deepseek-harness/issues/3270) records the production sample that identified `ArrayShift`, `MoveRange`, and `memmove` as the dominant stack. + +The affected streams have different wake-up, failure, cancellation, and disposal behavior. Their shared requirement is storage that preserves FIFO order without making those lifecycle decisions. + +## Decision + +`@deepseek-ai/dsh-deque` owns one zero-dependency circular array for Host and browser consumers. `pushBack()`, `pushFront()`, and `popFront()` change indices instead of moving the live range. A removal clears its slot immediately. The backing array doubles when full and halves when a non-empty deque reaches one quarter of capacity, so growth and compaction copy work remains amortized constant time and vacant storage stays bounded over interleaved queue use. + +The package has no singleton state, symbols, or class identity shared between Client plugins, so the Client bundle purity rule treats it as an inline-safe library. The Gateway browser artifact carries its deque implementation without introducing a module-table entry or a Cordis service. + +The Host Remote event source, each connected Client Remote event queue, the browser Remote stream inbox, each Session history follower, each Session control stream, and each Workspace follower store frames in this deque. Their owning classes retain all wake-up, failure, cancellation, buffered-drain, and disposal behavior. Session history uses front insertion to place constructor-seed events before live events received during its opening observation. + +Queue capacity, frame coalescing, overload rejection, and global agent admission remain consumer or application policy. The deque does not infer any of them from storage pressure. + +## Verification + +The deque unit suite covers FIFO order, front insertion, array-boundary wrapping, geometric growth, quarter-full compaction after interleaved enqueue and dequeue, clearing, reuse, and `undefined` entries. Focused coverage reports 100% statements, branches, functions, and lines for `packages/util/deque/src/index.ts`. + +The API Remote, Gateway, Session control/history, and Workspace follow suites exercise the migrated lifecycle behavior. They retain their package-owned ordering, failure, cancellation, and disposal assertions. + +The command `pnpm exec tsx packages/util/deque/benchmarks/drain.ts` ran on Node v26.0.0, arm64 macOS 26.4. Five samples per size produced these median deque drain times; enqueue time is outside the measurement: + +| Entries | Median drain | Nanoseconds per entry | +|---:|---:|---:| +| 250,000 | 1.705 ms | 6.818 ns | +| 500,000 | 2.541 ms | 5.082 ns | +| 1,000,000 | 4.668 ms | 4.668 ns | +| 2,000,000 | 9.656 ms | 4.828 ns | + +The checked-in benchmark makes the measurement reproducible, but CI does not enforce a wall-clock threshold. Deterministic unit coverage owns the algorithm and compaction paths; the benchmark demonstrates approximately linear drain work on the recorded runtime. + +## Alternatives considered + +**Array head removal.** Keeping `shift()` preserves the smallest source diff but repeats the production failure mode and provides no amortized constant-time guarantee. + +**A monotonic head cursor with occasional slicing.** This can provide amortized constant-time FIFO removal, but Session history also needs front insertion before concurrently buffered entries. A circular deque provides both operations through one storage rule without a special history prefix buffer. + +**A linked deque.** Linked nodes make every end operation constant time and release removed nodes immediately, but each frame also allocates a node and pointer fields. The circular array keeps contiguous storage and amortizes the less frequent copies. + +**An external deque dependency.** The required API is small, and the retention rule includes immediate slot clearing plus a specific shrink condition that the regression suite must exercise. A local zero-dependency utility keeps that storage lifecycle inspectable in both compiler faces; an external collection would still require the same integration and retention verification. + +## Consequences + +Draining a backlog performs linear deque work instead of quadratic array-range movement. Removed frame references become collectible before backing-storage compaction, and a stream that remains active does not retain every historical slot. + +The repository owns a small generic collection implementation and its compatibility surface. Changes to its indexing, growth, or shrink rules require focused ordering and compaction coverage because every migrated stream shares the result. + +Unbounded producers can still exhaust memory or delay consumers through the volume of legitimate per-frame work. Capacity and admission policy remain separate decisions rather than hidden behavior in a generic collection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.zh.md b/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.zh.md new file mode 100644 index 0000000000..1d0ff4813b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.zh.md @@ -0,0 +1,56 @@ +# Agent Note: 长期流队列的线性排空 + +Status: implemented + +[English](2026-08-28-linear-stream-queue-drain.md) | 中文 + +## 问题 + +当消费方忙碌时,长期存在的流队列可能积累数千个帧。在观测到的 V8 路径上,使用 `Array.prototype.shift()` 移除每个帧会移动剩余数组区间,因此排空 `N` 个排队帧会执行二次方级别的引用移动,并延迟同一事件循环上的无关工作。[Issue #3270](https://github.com/deepseek-harness/deepseek-harness/issues/3270) 记录了把 `ArrayShift`、`MoveRange` 和 `memmove` 识别为主要堆栈的生产采样。 + +受影响的流具有不同的唤醒、失败、取消和 disposal 行为。它们的共同要求是保持 FIFO 顺序、同时不替它们作出这些生命周期决策的存储。 + +## 决策 + +`@deepseek-ai/dsh-deque` 为 Host 和浏览器消费方拥有一个零依赖环形数组。`pushBack()`、`pushFront()` 和 `popFront()` 改变索引,而不移动存活区间。移除会立即清空对应槽位。后备数组在满载时翻倍,在非空双端队列达到四分之一容量时减半,因此扩容和压缩的复制工作保持摊销常数时间,且交错队列使用期间的空闲存储保持有界。 + +该包没有 Client 插件之间共享的 singleton 状态、符号或类身份,因此 Client bundle purity 规则把它视为可内联库。Gateway 浏览器产物携带其双端队列实现,而不引入 module-table 条目或 Cordis 服务。 + +Host Remote 事件源、每个已连接 Client 的 Remote 事件队列、浏览器 Remote 流 inbox、每个会话历史 follower、每个会话控制流和每个 Workspace follower 都在此双端队列中存储帧。它们的所属类保留全部唤醒、失败、取消、缓冲排空和 disposal 行为。会话历史使用前插,把构造器种子事件放在打开观察期间收到的 live 事件之前。 + +队列容量、帧合并、过载拒绝和全局 agent admission 仍是消费方或应用策略。双端队列不会根据存储压力推断其中任何策略。 + +## 验证 + +双端队列单元测试覆盖 FIFO 顺序、前插、数组边界环绕、几何扩容、交错入队和出队后的四分之一满压缩、清空、复用与 `undefined` 条目。聚焦覆盖率报告显示 `packages/util/deque/src/index.ts` 的语句、分支、函数和行均为 100%。 + +API Remote、Gateway、会话控制/历史和 Workspace follow 测试覆盖迁移后的生命周期行为。它们保留所属包对顺序、失败、取消和 disposal 的断言。 + +命令 `pnpm exec tsx packages/util/deque/benchmarks/drain.ts` 在 Node v26.0.0、arm64 macOS 26.4 上运行。每个规模采样五次,得到以下双端队列排空时间中位数;测量不包含入队时间: + +| 条目数 | 排空中位数 | 每条目纳秒数 | +|---:|---:|---:| +| 250,000 | 1.705 ms | 6.818 ns | +| 500,000 | 2.541 ms | 5.082 ns | +| 1,000,000 | 4.668 ms | 4.668 ns | +| 2,000,000 | 9.656 ms | 4.828 ns | + +检入的 benchmark 使该测量可复现,但 CI 不强制墙钟时间阈值。确定性单元覆盖率负责算法和压缩路径;benchmark 在所记录运行时上证明排空工作近似线性。 + +## 考虑过的替代方案 + +**数组头部移除。** 保留 `shift()` 能得到最小源码差异,但会重复生产故障模式,也不提供摊销常数时间保证。 + +**单调头游标配合偶尔切片。** 这可以提供摊销常数时间的 FIFO 移除,但会话历史还需要在并发缓冲条目之前执行前插。环形双端队列通过一项存储规则同时提供两种操作,不需要特殊的历史前缀缓冲区。 + +**链式双端队列。** 链式节点让每个端点操作都保持常数时间,并立即释放已移除节点,但每个帧还会分配一个节点和指针字段。环形数组保持连续存储,并摊销频率较低的复制。 + +**外部双端队列依赖。** 所需 API 很小,保留规则包括立即清空槽位以及回归测试必须覆盖的特定缩容条件。本地零依赖工具让两个编译 face 都能检查该存储生命周期;外部集合仍需相同的集成和保留验证。 + +## 后果 + +排空 backlog 会执行线性双端队列工作,而不是二次方级别的数组区间移动。已移除帧的引用在后备存储压缩前即可回收,持续活动的流也不会保留每个历史槽位。 + +仓库拥有一项小型通用集合实现及其兼容性接口。对其索引、扩容或缩容规则的修改需要聚焦的顺序和压缩覆盖,因为每个已迁移流都会共享结果。 + +无界生产者仍可能通过合法逐帧工作的数量耗尽内存或延迟消费方。容量和 admission 策略仍是独立决策,而不是通用集合中的隐藏行为。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e243871af1..ae563b4b35 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: 3c3699228bf5d4c0f49f8134d7c342f452de92c9 -config-catalog.zh.md: 0d96acf23a302d7365e6ebdb4aa3fe7cba4af400 +config-catalog.md: 4909347c5f2648962b74a7965d1e7dd18521f3b7 +config-catalog.zh.md: 98270d431a601058341eb1b5dfbb59ea4505a15e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c3699228b..4909347c5f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -290,7 +290,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:118`](../packages/api/gateway/src/index.ts) @@ -3524,6 +3524,7 @@ 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-deque` ([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-web-profile` ([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d96acf23a..98270d431a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -292,7 +292,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:118`](../packages/api/gateway/src/index.ts) @@ -3525,6 +3525,7 @@ 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-deque`([`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-web-profile`([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 41e0d5d0e8..3b9abcda71 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: f9cafb86b22012c2dc1198ff810ededd988911f9 -module-graph.zh.md: ec4ac506cfef4d8ceaf3c5f83f07dad1fee66c50 +module-graph.md: 69a4ff1313710923e32e0e2bb7d702b34f169c7f +module-graph.zh.md: 2f6fd8b05e67eb8b86cd6cef376cd07456cb5c2f diff --git a/docs/module-graph.md b/docs/module-graph.md index f9cafb86b2..69a4ff1313 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_launch_environment["launch-environment"] pkg_native_command["native-command"] @@ -359,6 +360,7 @@ flowchart TD end pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants + pkg_deque --> pkg_invariants pkg_home_paths --> pkg_invariants pkg_launch_environment --> pkg_invariants pkg_native_command --> pkg_invariants @@ -1738,6 +1740,7 @@ flowchart TD | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`deque`](../packages/util/deque) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`launch-environment`](../packages/util/launch-environment) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ec4ac506cf..2f6fd8b05e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -12,6 +12,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_launch_environment["launch-environment"] pkg_native_command["native-command"] @@ -361,6 +362,7 @@ flowchart TD end pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants + pkg_deque --> pkg_invariants pkg_home_paths --> pkg_invariants pkg_launch_environment --> pkg_invariants pkg_native_command --> pkg_invariants @@ -1740,6 +1742,7 @@ flowchart TD | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`deque`](../packages/util/deque) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`launch-environment`](../packages/util/launch-environment) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d5601ab188..aed918631c 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -56,6 +56,7 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 0dd56cc72d..c62fa5f522 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -6,6 +6,7 @@ import { type RemoteStreamClientMessage, type RemoteStreamServerMessage, } from '../stream-protocol.ts' +import { Deque } from '@deepseek-ai/dsh-deque' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' const INTERNAL_BASE = 'http://dsh.internal' @@ -311,13 +312,13 @@ function sleep(ms: number, signal: AbortSignal): Promise { } class StreamInbox { - private readonly frames: RemoteStreamServerMessage[] = [] + private readonly frames = new Deque() private wake: (() => void) | undefined private failure: Error | undefined push(frame: RemoteStreamServerMessage): void { if (this.failure !== undefined) return - this.frames.push(frame) + this.frames.pushBack(frame) this.wake?.() this.wake = undefined } @@ -325,17 +326,17 @@ class StreamInbox { fail(error: unknown): void { if (this.failure !== undefined) return this.failure = error instanceof Error ? error : new Error(String(error), { cause: error }) - this.frames.length = 0 + this.frames.clear() this.wake?.() this.wake = undefined } async next(): Promise { - while (this.frames.length === 0) { + while (this.frames.size === 0) { if (this.failure !== undefined) throw this.failure await new Promise((resolve) => { this.wake = resolve }) } - return this.frames.shift() as RemoteStreamServerMessage + return this.frames.popFront() as RemoteStreamServerMessage } } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1d754b05a7..07423f9968 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import z from '@deepseek-ai/schemastery' @@ -887,13 +888,13 @@ type RemoteEventWireFrame = /** Pull-driven queue owned by one connected Client event generation. */ class RemoteEventQueue { - private readonly frames: RemoteEventWireFrame[] = [] + private readonly frames = new Deque() private waiter: (() => void) | undefined private closed = false push(frame: RemoteEventWireFrame): void { if (this.closed) return - this.frames.push(frame) + this.frames.pushBack(frame) this.waiter?.() } @@ -908,7 +909,7 @@ class RemoteEventQueue { signal.addEventListener('abort', abort, { once: true }) try { while (true) { - while (this.frames.length > 0) yield this.frames.shift() as RemoteEventWireFrame + while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame if (this.closed || signal.aborted) return await new Promise((resolve) => { this.waiter = resolve }) this.waiter = undefined diff --git a/packages/api/gateway/tsconfig.client.json b/packages/api/gateway/tsconfig.client.json index 31df1266af..aa14fb8a95 100644 --- a/packages/api/gateway/tsconfig.client.json +++ b/packages/api/gateway/tsconfig.client.json @@ -24,6 +24,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/deque" + }, { "path": "../../util/crypto" } diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 54d5f964f3..874f7b8daf 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -34,6 +34,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/deque" + }, { "path": "../../util/timeout" } diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a271fe29a..66efeb1bd0 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -55,6 +55,7 @@ "lib/types/**/*.d.ts" ], "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index e775d3a5ae..b37136079d 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -8,6 +8,7 @@ import type { TypertRemoteEventOutcome, TypertRemoteEventSource, } from '@deepseek-ai/dsh-api-gateway' +import { Deque } from '@deepseek-ai/dsh-deque' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import { isJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session' @@ -79,13 +80,13 @@ function remoteEventSource(ctx: Context): TypertRemoteEventSource { /** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */ class RemoteEventQueue { - private readonly buffer: TypertRemoteEventDispatch[] = [] + private readonly buffer = new Deque() private waiter: (() => void) | undefined private done = false push(frame: TypertRemoteEventDispatch): boolean { if (this.done) return false - this.buffer.push(frame) + this.buffer.pushBack(frame) this.waiter?.() return true } @@ -93,8 +94,8 @@ class RemoteEventQueue { private end(reason: unknown): void { if (this.done) return this.done = true - const buffered = this.buffer.splice(0) - for (const dispatch of buffered) { + while (this.buffer.size > 0) { + const dispatch = this.buffer.popFront() as TypertRemoteEventDispatch if ('context' in dispatch) dispatch.reject(reason) } this.waiter?.() @@ -106,7 +107,7 @@ class RemoteEventQueue { try { while (true) { if (this.done || signal.aborted) return - while (this.buffer.length > 0) yield this.buffer.shift() as TypertRemoteEventDispatch + while (this.buffer.size > 0) yield this.buffer.popFront() as TypertRemoteEventDispatch await new Promise((resolve) => { this.waiter = resolve }) this.waiter = undefined } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 4dd513bdeb..d344c6be3d 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -42,6 +42,9 @@ { "path": "../../core/scope" }, + { + "path": "../../util/deque" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 0cdd53a2a3..a8725bb649 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -73,6 +73,7 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 81b1092d9f..db79f0a594 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { Deque } from '@deepseek-ai/dsh-deque' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage, @@ -129,13 +130,13 @@ export class SessionControlController { } class ControlQueue { - private readonly buffer: SessionControlFrame[] = [] + private readonly buffer = new Deque() private wake: (() => void) | undefined private done = false push(frame: SessionControlFrame): void { if (this.done) return - this.buffer.push(frame) + this.buffer.pushBack(frame) const wake = this.wake this.wake = undefined wake?.() @@ -154,14 +155,14 @@ class ControlQueue { signal.addEventListener('abort', onAbort, { once: true }) try { while (!this.done && !signal.aborted) { - const frame = this.buffer.shift() + const frame = this.buffer.popFront() if (frame !== undefined) { yield frame continue } await new Promise((resolve) => { this.wake = resolve }) } - while (this.buffer.length > 0 && !signal.aborted) yield this.buffer.shift() as SessionControlFrame + while (this.buffer.size > 0 && !signal.aborted) yield this.buffer.popFront() as SessionControlFrame } finally { signal.removeEventListener('abort', onAbort) this.end() diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index d78509e49b..99f42fe310 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -1,6 +1,7 @@ /** Cold Session history pagination and live-event source. */ import type { Context } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' @@ -88,7 +89,7 @@ export class SessionHistoryController { validateFollowRequest(request) const { address } = request const target = addressId(address) - const buffered: SessionEvent[] = [] + const buffered = new Deque() let snapshotCursor: number | undefined let wake: (() => void) | undefined const notify = (): void => { @@ -104,7 +105,7 @@ export class SessionHistoryController { this.closeFollowers.add(close) const disposeEvent = this.ctx.on('session/event', (session, event) => { if (session.id !== target) return - buffered.push(event) + buffered.pushBack(event) notify() }, { global: true }) const disposeCreated = this.ctx.on('session/created', (session) => { @@ -115,7 +116,9 @@ export class SessionHistoryController { const suffix = session.events.slice(snapshotCursor === undefined ? session.firstLiveSeq : snapshotCursor + 1) - buffered.unshift(...suffix) + for (let index = suffix.length - 1; index >= 0; index -= 1) { + buffered.pushFront(suffix[index] as SessionEvent) + } notify() }, { global: true }) const onAbort = (): void => { notify() } @@ -148,7 +151,7 @@ export class SessionHistoryController { } let nextSeq = cursor + 1 while (!follower.closed && !signal.aborted) { - const item = buffered.shift() + const item = buffered.popFront() if (item === undefined) { await new Promise((resolve) => { wake = resolve }) continue diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index bea21672b7..aba87f9107 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -32,6 +32,7 @@ { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, + { "path": "../../util/deque" }, { "path": "../../util/native-command" }, { "path": "../../preset/agent-presets" }, { "path": "../../runtime-diagnostics/invariants" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 14586d912e..c993e58cbf 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -70,6 +70,7 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index dcb1598c59..57d38f18e5 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -1,6 +1,7 @@ /** Reconnect-safe Workspace baseline and increment producer. */ import type { Context } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -139,14 +140,14 @@ function sameStrings(left: readonly string[], right: readonly string[]): boolean } class WorkspaceFollower { - private readonly frames: WorkspaceFollowFrame[] = [] + private readonly frames = new Deque() private waiting: (() => void) | undefined private closed = false push(frame: WorkspaceFollowFrame): void { /* v8 ignore next -- closed followers are removed before later publication can reach them. */ if (this.closed) return - this.frames.push(frame) + this.frames.pushBack(frame) this.waiting?.() } @@ -158,7 +159,7 @@ class WorkspaceFollower { async *read(signal: AbortSignal): AsyncIterable { while (!this.closed && !signal.aborted) { - const frame = this.frames.shift() + const frame = this.frames.popFront() if (frame !== undefined) { yield frame continue @@ -178,7 +179,7 @@ class WorkspaceFollower { this.waiting = finish signal.addEventListener('abort', finish, { once: true }) /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ - if (signal.aborted || this.closed || this.frames.length > 0) finish() + if (signal.aborted || this.closed || this.frames.size > 0) finish() }) } } diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 4b98892781..e4a57edebe 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -20,6 +20,7 @@ { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, + { "path": "../../util/deque" }, { "path": "../../workspace/workspace" } ] } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 984a6a6828..3b679d3899 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index dfba9d3271..2a5453caa5 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/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/util/README.md -README.md: d1f48f165952a283b231e416b3391585cfee81a8 -README.zh.md: 69eb0cf9bb5845a4aeeb3880ef70fac049efbade +README.md: ea420501c4a7f454c791f69828a77e32f87a8381 +README.zh.md: 25000c76ad8702dfec14edd629d96e61abf52e4e diff --git a/packages/util/README.md b/packages/util/README.md index d1f48f1659..ea420501c4 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the zero-dependency utility family: atomic file writes, branded ids, harness home paths, the launch environment, native commands, output retention, and timeouts." +description: "Package map for the zero-dependency utility family: atomic file writes, branded ids, deques, harness home paths, the launch environment, native commands, output retention, and timeouts." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, UUIDs, Harness-home paths, launch environments, native commands, output retention, and timeout handling. Every package here is a library: it registers no service or event, and the consuming capability retains the business semantics. +The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, UUIDs, Harness-home paths, launch environments, native commands, output retention, and timeout handling. Every package here is a library: it registers no service or event, and the consuming capability retains the business semantics. ## Table of Contents @@ -28,6 +28,7 @@ Each package provides one primitive; open a package page for how to use it. |---|---| | [`brand/`](brand/README.md) | Compile-time-only nominal brands for ids that cross package boundaries | | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive | +| [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage | | [`home-paths/`](home-paths/README.md) | Resolves the single Harness home and joins shared user-data paths | | [`launch-environment/`](launch-environment/README.md) | Frozen launch environment that remembers which layer supplied each value | | [`atomic-write/`](atomic-write/README.md) | Atomic file replacement and cross-process writer locking | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 69eb0cf9bb..25000c76ad 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -1,5 +1,5 @@ --- -description: "零依赖工具家族的包映射:原子文件写入、品牌化 id、harness 主目录路径、启动环境、原生命令、输出保留与超时。" +description: "零依赖工具家族的包映射:原子文件写入、品牌化 id、双端队列、harness 主目录路径、启动环境、原生命令、输出保留与超时。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、UUID、Harness home 路径、启动环境、原生命令、输出保留和超时处理。这里的每个包都是库:它不注册服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、UUID、Harness home 路径、启动环境、原生命令、输出保留和超时处理。这里的每个包都是库:它不注册服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -28,6 +28,7 @@ kind: "package-group" |---|---| | [`brand/`](brand/README.zh.md) | 为跨越包边界的 id 提供仅编译期的名义品牌 | | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID | +| [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 | | [`home-paths/`](home-paths/README.zh.md) | 解析统一的 Harness 主目录并拼接共享的用户数据路径 | | [`launch-environment/`](launch-environment/README.zh.md) | 冻结的启动环境,记住每个值来自哪一层 | | [`atomic-write/`](atomic-write/README.zh.md) | 原子文件替换与跨进程写锁 | diff --git a/packages/util/deque/README.i18n.yaml b/packages/util/deque/README.i18n.yaml new file mode 100644 index 0000000000..aa7b93e2a1 --- /dev/null +++ b/packages/util/deque/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/util/deque/README.md +README.md: e029ecf574731bb2860dbc56383b93a9282dddfe +README.zh.md: 308f367e91c7345088eea0f5b2bccc0ad3539af7 diff --git a/packages/util/deque/README.md b/packages/util/deque/README.md new file mode 100644 index 0000000000..e029ecf574 --- /dev/null +++ b/packages/util/deque/README.md @@ -0,0 +1,104 @@ +--- +description: "Circular deque for Host and browser packages that need amortized constant-time queue operations, immediate release of removed entries, and bounded vacant storage." +kind: "package-library" +--- + +# @deepseek-ai/dsh-deque + +English | [中文](README.zh.md) + +## Summary + +`dsh-deque` lets Host and browser packages drain long-lived in-process queues without moving every remaining entry after each removal. Callers append or prepend entries and remove them from the front with amortized constant-time operations. The deque owns entry order and backing-storage release; each consumer still owns wake-up, failure, cancellation, capacity, and overload behavior. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +### When to use it + +Use `Deque` when entries can accumulate across asynchronous work and the consumer needs FIFO removal, optional front insertion, or explicit queue clearing. Finite local worklists can stay as arrays when their maximum size makes head removal cost irrelevant. + +### Entry point + +Import the deque, append entries at the tail, and check `size` before removing an entry whose type may include `undefined`: + +```ts +import { Deque } from '@deepseek-ai/dsh-deque' + +const frames = new Deque() +frames.pushBack('first') +frames.pushFront('before-first') + +while (frames.size > 0) { + console.log(frames.popFront()) +} +``` + +The methods do not impose a queue limit or translate consumer failures. See [`src/index.ts`](src/index.ts) for the exact TypeScript contract. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The deque stores entries in a circular array. Removing an entry clears that slot immediately, while geometric growth and quarter-full shrinking keep copying work amortized constant time and prevent a head cursor from retaining indefinitely growing vacant storage. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Circular deque operations and backing-storage lifecycle | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; ordering and storage lifecycle are exercised by unit tests) | +| [`tests/deque.spec.ts`](tests/deque.spec.ts) | FIFO, front insertion, wrapping, growth, compaction, clearing, and reuse coverage | +| [`benchmarks/drain.ts`](benchmarks/drain.ts) | Reproducible backlog-drain timing across increasing queue sizes | + +
+ +----- + + +## Further Exploration + +- [Utility package map](../README.md) — the other zero-dependency primitives shared across package groups. +- [Linear stream queue decision](../../../.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.md) — why production streams use this deque instead of array head removal. + +----- + + +## Model Experience + +None, as this in-process collection registers nothing model-facing. + +#### KV Cache effect + +Nothing here enters a model request, so provider cache reuse is unaffected. + +## Known Limitations and Deferred Work + + + +- **No capacity policy** — the deque does not bound, coalesce, or reject entries; each consumer must define overload behavior appropriate to its stream. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/deque/README.zh.md b/packages/util/deque/README.zh.md new file mode 100644 index 0000000000..308f367e91 --- /dev/null +++ b/packages/util/deque/README.zh.md @@ -0,0 +1,104 @@ +--- +description: "供 Host 和浏览器包使用的环形双端队列,提供摊销常数时间的队列操作、已移除条目的即时释放和有界空闲存储。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-deque + +[English](README.md) | 中文 + +## 概述 + +`dsh-deque` 让 Host 和浏览器包可以排空长期存在的进程内队列,而无需在每次移除后移动所有剩余条目。调用方可以追加或前插条目,并以摊销常数时间从前端移除。双端队列负责条目顺序和后备存储释放;唤醒、失败、取消、容量和过载行为仍由各消费方负责。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +### 何时使用 + +当条目可能在异步工作期间持续积累,且消费方需要 FIFO 移除、可选前插或显式清空队列时,使用 `Deque`。如果有限本地工作列表的最大规模使头部移除成本无关紧要,它可以继续使用数组。 + +### 入口 + +导入双端队列,在尾部追加条目;当条目类型可能包含 `undefined` 时,在移除前检查 `size`: + +```ts +import { Deque } from '@deepseek-ai/dsh-deque' + +const frames = new Deque() +frames.pushBack('first') +frames.pushFront('before-first') + +while (frames.size > 0) { + console.log(frames.popFront()) +} +``` + +这些方法不施加队列限制,也不转换消费方失败。准确的 TypeScript 约定见 [`src/index.ts`](src/index.ts)。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +双端队列把条目存入环形数组。移除条目会立即清空对应槽位;按几何级数扩容并在四分之一满时缩容,使复制工作保持摊销常数时间,并防止头游标保留持续增长的空闲存储。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 环形双端队列操作与后备存储生命周期 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;顺序和存储生命周期由单元测试覆盖) | +| [`tests/deque.spec.ts`](tests/deque.spec.ts) | FIFO、前插、环绕、扩容、压缩、清空和复用覆盖 | +| [`benchmarks/drain.ts`](benchmarks/drain.ts) | 随队列规模增长的可复现 backlog 排空计时 | + +
+ +----- + + +## 进一步探索 + +- [工具包映射](../README.zh.md)——跨包组共享的其他零依赖原语。 +- [线性流队列决策](../../../.agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.zh.md)——生产流为何使用本双端队列而非数组头部移除。 + +----- + + +## 模型体验 + +无,因为这个进程内集合不注册任何面向模型的内容。 + +#### KV 缓存影响 + +这里的内容不会进入模型请求,因此不影响提供方缓存复用。 + +## 已知限制与延期工作 + + + +- **没有容量策略**——双端队列不会限制、合并或拒绝条目;每个消费方必须定义适合其流的过载行为。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/util/deque/benchmarks/drain.ts b/packages/util/deque/benchmarks/drain.ts new file mode 100644 index 0000000000..51b067741d --- /dev/null +++ b/packages/util/deque/benchmarks/drain.ts @@ -0,0 +1,36 @@ +import { performance } from 'node:perf_hooks' +import { Deque } from '../src/index.ts' + +const sizes = [250_000, 500_000, 1_000_000, 2_000_000] +const samples = 5 + +function drain(size: number): { readonly milliseconds: number; readonly checksum: number } { + const deque = new Deque() + for (let value = 0; value < size; value += 1) deque.pushBack(value) + const started = performance.now() + let checksum = 0 + while (deque.size > 0) checksum += deque.popFront() as number + return { milliseconds: performance.now() - started, checksum } +} + +function median(values: readonly number[]): number { + const ordered = values.toSorted((left, right) => left - right) + return ordered[Math.floor(ordered.length / 2)] as number +} + +drain(sizes[0] as number) +for (const size of sizes) { + const expected = size * (size - 1) / 2 + const durations: number[] = [] + for (let sample = 0; sample < samples; sample += 1) { + const result = drain(size) + if (result.checksum !== expected) throw new Error(`invalid checksum for ${String(size)} entries`) + durations.push(result.milliseconds) + } + const milliseconds = median(durations) + console.log(JSON.stringify({ + size, + medianMilliseconds: Number(milliseconds.toFixed(3)), + nanosecondsPerEntry: Number((milliseconds * 1_000_000 / size).toFixed(3)), + })) +} diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json new file mode 100644 index 0000000000..a159d0dcf9 --- /dev/null +++ b/packages/util/deque/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-deque", + "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", + "version": "0.1.2-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/deque" + }, + "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:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/util/deque/src/index.ts b/packages/util/deque/src/index.ts new file mode 100644 index 0000000000..8d117b7499 --- /dev/null +++ b/packages/util/deque/src/index.ts @@ -0,0 +1,95 @@ +/** + * Zero-dependency circular deque for queues that retain entries across asynchronous work. + * @module @deepseek-ai/dsh-deque + */ + +const MIN_CAPACITY = 16 + +/** + * A circular deque with amortized constant-time insertion and removal. + * Removed entries are cleared immediately, and sparse storage shrinks after + * the live entry count reaches one quarter of its capacity. + */ +export class Deque { + private buffer = new Array(MIN_CAPACITY) + private head = 0 + private count = 0 + + /** Number of entries available to remove. */ + get size(): number { + return this.count + } + + /** + * Append one entry after the current tail. + * @param value - entry to append. + */ + pushBack(value: T): void { + this.ensureCapacity() + const tail = this.head + this.count + this.buffer[tail < this.buffer.length ? tail : tail - this.buffer.length] = value + this.count += 1 + } + + /** + * Insert one entry before the current head. + * @param value - entry to prepend. + */ + pushFront(value: T): void { + this.ensureCapacity() + this.head = this.head === 0 ? this.buffer.length - 1 : this.head - 1 + this.buffer[this.head] = value + this.count += 1 + } + + /** + * Remove the current head entry and clear its retained reference. + * Callers whose element type includes `undefined` use {@link size} to + * distinguish an empty deque from an `undefined` entry. + * @returns the removed entry, or `undefined` when the deque is empty. + */ + popFront(): T | undefined { + if (this.count === 0) return undefined + const value = this.buffer[this.head] as T + this.buffer[this.head] = undefined + this.head += 1 + if (this.head === this.buffer.length) this.head = 0 + this.count -= 1 + this.compact() + return value + } + + /** Drop every entry and release the current backing storage. */ + clear(): void { + this.buffer = new Array(MIN_CAPACITY) + this.head = 0 + this.count = 0 + } + + private ensureCapacity(): void { + if (this.count < this.buffer.length) return + this.resize(this.buffer.length * 2) + } + + private compact(): void { + if (this.count === 0) { + this.head = 0 + return + } + if (this.buffer.length > MIN_CAPACITY && this.count <= this.buffer.length / 4) { + this.resize(Math.max(MIN_CAPACITY, this.buffer.length / 2)) + } + } + + private resize(capacity: number): void { + const next = new Array(capacity) + let source = this.head + for (let index = 0; index < this.count; index += 1) { + next[index] = this.buffer[source] + source += 1 + if (source === this.buffer.length) source = 0 + } + this.buffer = next + this.head = 0 + } +} diff --git a/packages/util/deque/src/invariant.ts b/packages/util/deque/src/invariant.ts new file mode 100644 index 0000000000..846a29ba4e --- /dev/null +++ b/packages/util/deque/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-deque`. + * @module @deepseek-ai/dsh-deque/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-deque' + +/** Cordis companion plugin name. */ +export const name = 'deque-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this pure utility owns no event stream or mutable data outside each deque; + * its ordering and storage lifecycle are exercised by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/deque/tests/deque.spec.ts b/packages/util/deque/tests/deque.spec.ts new file mode 100644 index 0000000000..c2a02f81c7 --- /dev/null +++ b/packages/util/deque/tests/deque.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { Deque } from '@deepseek-ai/dsh-deque' + +describe('Deque', () => { + it('removes tail-appended entries in FIFO order', () => { + const deque = new Deque() + expect(deque.size).toBe(0) + expect(deque.popFront()).toBeUndefined() + + deque.pushBack(1) + deque.pushBack(2) + + expect(deque.size).toBe(2) + expect(deque.popFront()).toBe(1) + expect(deque.popFront()).toBe(2) + expect(deque.size).toBe(0) + }) + + it('prepends entries before the existing head', () => { + const deque = new Deque() + deque.pushBack(3) + deque.pushFront(2) + deque.pushFront(1) + + expect([deque.popFront(), deque.popFront(), deque.popFront()]).toEqual([1, 2, 3]) + }) + + it('appends through the array boundary without growing', () => { + const deque = new Deque() + for (let value = 0; value < 8; value += 1) deque.pushBack(value) + for (let value = 0; value < 6; value += 1) expect(deque.popFront()).toBe(value) + for (let value = 8; value <= 16; value += 1) deque.pushBack(value) + + for (const value of [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) { + expect(deque.popFront()).toBe(value) + } + }) + + it('preserves order across wrapping, growth, and sparse compaction', () => { + const deque = new Deque() + for (let value = 0; value < 32; value += 1) deque.pushBack(value) + for (let value = 0; value < 24; value += 1) expect(deque.popFront()).toBe(value) + for (let value = 32; value < 128; value += 1) deque.pushBack(value) + + for (let value = 24; value < 128; value += 1) expect(deque.popFront()).toBe(value) + expect(deque.size).toBe(0) + }) + + it('drops retained storage and remains reusable after clear', () => { + const deque = new Deque() + for (let index = 0; index < 64; index += 1) deque.pushBack({ index }) + + deque.clear() + expect(deque.size).toBe(0) + expect(deque.popFront()).toBeUndefined() + + const value = {} + deque.pushBack(value) + expect(deque.popFront()).toBe(value) + }) + + it('uses size to distinguish an undefined entry from an empty deque', () => { + const deque = new Deque() + deque.pushBack(undefined) + + expect(deque.size).toBe(1) + deque.popFront() + expect(deque.size).toBe(0) + }) +}) diff --git a/packages/util/deque/tests/invariant.spec.ts b/packages/util/deque/tests/invariant.spec.ts new file mode 100644 index 0000000000..fad7278a46 --- /dev/null +++ b/packages/util/deque/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import * as DequeInvariant from '../src/invariant.ts' + +describe('deque invariant companion', () => { + it('registers its explained empty runtime invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + const fiber = await ctx.plugin(DequeInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-deque', () => {}) + }).toThrow(/already registered/) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/util/deque/tsconfig.json b/packages/util/deque/tsconfig.json new file mode 100644 index 0000000000..779effc3cc --- /dev/null +++ b/packages/util/deque/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9049facb91..a7a3e7be33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -624,6 +624,9 @@ importers: packages/api/gateway: dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -667,6 +670,9 @@ importers: packages/api/remotes: dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -740,6 +746,9 @@ importers: packages/api/session-controller: dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -880,6 +889,9 @@ importers: packages/api/workspace-controller: dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque zod: specifier: ^4.4.3 version: 4.4.3 @@ -9677,6 +9689,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/deque: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/util/home-paths: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index dd25b0dfcf..6d7db99497 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -91,9 +91,10 @@ describe('client bundle purity gate', () => { expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/) }) - it('lets inline-safe wire layers inline', () => { + it('lets inline-safe libraries inline', () => { expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-deque')).toBeNull() expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull() expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/) diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index f7c5fd8c51..e4744d9514 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -78,6 +78,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/atomic-write': 'Zero-dependency filesystem write utility.', 'packages/util/brand': 'Type-only branding primitive erased at compile time.', 'packages/util/crypto': 'Zero-dependency identifier minting utility.', + 'packages/util/deque': 'Zero-dependency circular deque utility.', 'packages/util/home-paths': 'Zero-dependency harness-home path resolver.', 'packages/util/launch-environment': 'Zero-dependency environment resolver.', 'packages/util/native-command': 'Host-side subprocess runner utility.', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c8f7e84d42..8f2800d88d 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to PTC mode in dsh-tools.' }, 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, + 'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 759b985d1b..614047ccf4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -303,6 +303,8 @@ "@deepseek-ai/dsh-credentials-local/invariant": ["./packages/credentials/credentials-local/src/invariant.ts"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions/invariant": ["./packages/llm/deepseek-llm-api-extensions/src/invariant.ts"], + "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], + "@deepseek-ai/dsh-deque/invariant": ["./packages/util/deque/src/invariant.ts"], "@deepseek-ai/dsh-e2b": ["./packages/e2b/e2b/src"], "@deepseek-ai/dsh-e2b/invariant": ["./packages/e2b/e2b/src/invariant.ts"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 561cee9b17..fa024a09f1 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -132,6 +132,7 @@ { "path": "./packages/util/home-paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/crypto" }, + { "path": "./packages/util/deque" }, { "path": "./packages/util/workspace-path" }, { "path": "./packages/util/output-retention" }, { "path": "./packages/util/atomic-write" }, From 84df0f11ae9ab9130e9286a229d80f90c9749abd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 28 Aug 2026 17:53:13 +0800 Subject: [PATCH 046/429] fix(web): use primary color for subagent setting label --- .../src/client/SubagentModelSelectionCard.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 3508ec8e48..4139daea2a 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -11,7 +11,7 @@ gap: 16px; font-size: 13px; line-height: 1.5; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary); } .toggleLabel { From 143748bae95644cda5fc091557e778eef4aa366c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 28 Aug 2026 18:02:11 +0800 Subject: [PATCH 047/429] test(deque): pin storage release behavior --- packages/util/deque/tests/deque.spec.ts | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/util/deque/tests/deque.spec.ts b/packages/util/deque/tests/deque.spec.ts index c2a02f81c7..d585283461 100644 --- a/packages/util/deque/tests/deque.spec.ts +++ b/packages/util/deque/tests/deque.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { Deque } from '@deepseek-ai/dsh-deque' +function backingStorage(deque: Deque): readonly (T | undefined)[] { + // Storage retention is the behavior under test and has no public query API. + return (deque as unknown as { readonly buffer: readonly (T | undefined)[] }).buffer +} + describe('Deque', () => { it('removes tail-appended entries in FIFO order', () => { const deque = new Deque() @@ -40,19 +45,38 @@ describe('Deque', () => { const deque = new Deque() for (let value = 0; value < 32; value += 1) deque.pushBack(value) for (let value = 0; value < 24; value += 1) expect(deque.popFront()).toBe(value) + expect(backingStorage(deque)).toHaveLength(16) for (let value = 32; value < 128; value += 1) deque.pushBack(value) for (let value = 24; value < 128; value += 1) expect(deque.popFront()).toBe(value) expect(deque.size).toBe(0) + expect(backingStorage(deque)).toHaveLength(16) + }) + + it('releases a removed reference before sparse compaction', () => { + const deque = new Deque() + const removed = {} + deque.pushBack(removed) + deque.pushBack({}) + + expect(deque.popFront()).toBe(removed) + expect(backingStorage(deque)).not.toContain(removed) + expect(backingStorage(deque)).toHaveLength(16) }) it('drops retained storage and remains reusable after clear', () => { const deque = new Deque() - for (let index = 0; index < 64; index += 1) deque.pushBack({ index }) + const retained = {} + deque.pushBack(retained) + for (let index = 1; index < 64; index += 1) deque.pushBack({ index }) + const grownStorage = backingStorage(deque) deque.clear() expect(deque.size).toBe(0) expect(deque.popFront()).toBeUndefined() + expect(backingStorage(deque)).not.toBe(grownStorage) + expect(backingStorage(deque)).not.toContain(retained) + expect(backingStorage(deque)).toHaveLength(16) const value = {} deque.pushBack(value) From ececf8c170f786d94b6f8e1e1ca18692faaacab5 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 19:29:31 +0800 Subject: [PATCH 048/429] fix(web): address hero fish hover review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move hover enter/leave from the svg element to the stationary fishHitbox span so the CSS sway and SMIL morph share the same trigger surface and the animation cannot flicker when sway displaces the svg from the pointer. Gate the morph with (hover: hover) to match the CSS and prevent tap-sticky loops on touch devices. Remove residual headline changes unrelated to this PR: white-space: pre on .headlineText, data-testid="hero-headline", and corresponding getByTestId test assertions — these belong to #2397. Fix Agent Note coverage claim (was "asserting render states", now accurately "slot contract") and compress blowhole paragraph to current-state fact. --- ...08-12-hero-fish-hover-swim-morph.i18n.yaml | 4 +-- .../2026-08-12-hero-fish-hover-swim-morph.md | 4 +-- ...026-08-12-hero-fish-hover-swim-morph.zh.md | 4 +-- .../src/client/skeleton/EmptyHero.tsx | 30 ++++++++++--------- .../src/client/skeleton/HeroShell.module.css | 1 - .../tests/skeleton.client.spec.tsx | 6 ++-- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml index 47ca707476..554c1976d5 100644 --- a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.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-hero-fish-hover-swim-morph.md -2026-08-12-hero-fish-hover-swim-morph.md: 2ae090f2e9640fc68091e918a644b4f793d1a8f4 -2026-08-12-hero-fish-hover-swim-morph.zh.md: eac0047a083d410b1f538954088a29ef721fe054 +2026-08-12-hero-fish-hover-swim-morph.md: c485f94e244040d7e72838065f977e698391c00d +2026-08-12-hero-fish-hover-swim-morph.zh.md: 46fa50732bf98aa5e3afb503e84f8e23001499b2 diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md index 2ae090f2e9..c485f94e24 100644 --- a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md @@ -18,10 +18,10 @@ The morphing fish reaches the hero as the fallback of the `conversation.hero.bra **Vector-tool path editing for the morphs.** No interactive tool in the loop; programmatic weighted deformation was chosen because it guarantees the identical command structure SMIL `d` interpolation requires and makes amplitudes reviewable numbers. -**Blowhole spout on hover.** Shipped first as three rising beads (richer whale-blow fountain, bubble-cluster, and ballistic-arc shapes were built and rejected in review), then removed entirely at the user's request: hover keeps only the shape morph and sway. +**Blowhole spout on hover.** Removed at the user's request; hover keeps only shape morph and sway. **Occupying the hero slot with the official mark.** The previous arrangement; rejected because the static occupant shadowed the animated fallback, and animating the occupant instead would need the forbidden cross-package value import. ## Consequences -The hover swim is decorative (`aria-hidden`) and reduced-motion-safe (static logo on hover). The sway CSS targets the stationary `.fishHitbox` wrapper, so a slot occupant would sway too; the body morph lives only in the fallback `HeroFish`. Coverage is the component suite (`skeleton.client.spec.tsx`) asserting render states; the keyless snapshot harness records transcripts, not browser animation, so visual verification stays manual. Regenerating the morph targets requires re-running the (uncommitted) deformation script against `FISH_LOGO_PATH`; if the logo geometry ever changes, the baked constants must be regenerated with it. +The hover swim is decorative (`aria-hidden`) and reduced-motion-safe (static logo on hover). The sway CSS targets the stationary `.fishHitbox` wrapper, so a slot occupant would sway too; the body morph lives only in the fallback `HeroFish`. Coverage is the `skeleton.client.spec.tsx` suite asserting slot contract (name, owner props, fallback existence); the keyless snapshot harness records transcripts, not browser animation, so visual verification of the morph stays manual. Regenerating the morph targets requires re-running the (uncommitted) deformation script against `FISH_LOGO_PATH`; if the logo geometry ever changes, the baked constants must be regenerated with it. diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md index eac0047a08..46fa50732b 100644 --- a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md @@ -18,10 +18,10 @@ hover New Session 主页的鲸鱼(`dsh-client-ui-conversation` 的 `EmptyHero. **用矢量工具编辑路径做变形。** 流程中没有可交互的工具;选择程序化加权变形,因为它保证 SMIL `d` 插值所要求的完全一致的命令结构,且振幅是可评审的数字。 -**hover 气孔喷水。** 最初以三颗上升水珠的形式上线(更复杂的鲸鱼喷泉、泡泡簇、弹道水花造型都做出来后在评审中被否),随后按用户要求整体移除:hover 只保留形状变形与摇摆。 +**hover 气孔喷水。** 按用户要求移除;hover 只保留形状变形与摇摆。 **让官方标志占据主页 slot。** 即先前的安排;否决,因为静态 occupant 会遮住动画 fallback,而给 occupant 加动画又需要被禁止的跨包 value import。 ## 影响 -hover 游动是纯装饰(`aria-hidden`)且对 reduced-motion 安全(hover 保持静态 logo)。摇摆 CSS 作用于外层静止的 `.fishHitbox`,因此换成 slot occupant 也会摇摆;身体变形只存在于 fallback 的 `HeroFish` 中。覆盖由组件测试(`skeleton.client.spec.tsx`)断言渲染状态;keyless 快照体系记录的是对话转录而非浏览器动画,视觉验证仍需人工。重新生成变形目标需要对 `FISH_LOGO_PATH` 重跑(未入库的)变形脚本;若 logo 几何将来变化,烘焙常量必须随之重新生成。 +hover 游动是纯装饰(`aria-hidden`)且对 reduced-motion 安全(hover 保持静态 logo)。摇摆 CSS 作用于外层静止的 `.fishHitbox`,因此换成 slot occupant 也会摇摆;身体变形只存在于 fallback 的 `HeroFish` 中。覆盖由 `skeleton.client.spec.tsx` 断言 slot 合约(名称、owner props、fallback 存在性);keyless 快照体系记录对话转录而非浏览器动画,变形的视觉验证仍需人工。重新生成变形目标需要对 `FISH_LOGO_PATH` 重跑(未入库的)变形脚本;若 logo 几何将来变化,烘焙常量必须随之重新生成。 diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 6cbf63a20b..90c8a32140 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -126,13 +126,12 @@ const HERO_SWIM_DOWN_PATH = * itself morphs — SMIL interpolates `d` through the tail-up and tail-down * targets on the same 1.6s period, so the tail wags and the fin flutters in * real curve deformation. Decorative — hidden from the accessibility tree; - * reduced motion keeps the static filled logo on hover. + * reduced motion keeps the static filled logo on hover (sampled at + * mouseenter; a mid-hover preference change takes effect on the next enter). + * @param props.hovering - driven by the hitbox parent's pointer state. * @returns the fish svg element. */ -function HeroFish() { - // Hover echo for the SMIL body morph: SMIL cannot ride CSS media queries, - // so the reduced-motion check gates the state instead of the style. - const [hovering, setHovering] = useState(false) +function HeroFish({ hovering }: { hovering: boolean }) { return (
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} - + { + if (window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) { + setHovering(true) + } + }} + onMouseLeave={() => { setHovering(false) }} + > {renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, { - fallback: , + fallback: , })} - + {t('hero.headline')} {t('hero.preview')} diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index f07152b3d2..889a140fe6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -41,7 +41,6 @@ .headlineText { grid-row: 1; grid-column: 2; - white-space: pre; } .previewBadge { diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index d40e0d1ed1..ed8a222e4f 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -311,7 +311,7 @@ describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const renderSlot = vi.fn(() => null) const view = render() - expect(view.getByTestId('hero-headline').textContent).toBe('Into the Unknown') + expect(view.getByText('Into the Unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() expect(renderSlot).toHaveBeenCalledOnce() expect(renderSlot.mock.calls[0]?.[0]).toBe('conversation.hero.brand.mark') @@ -439,7 +439,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByTestId('hero-headline').textContent).toBe('探索未至之境') + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -505,7 +505,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByTestId('hero-headline').textContent).toBe('探索未至之境') + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) From 9055a8af3accfb9109203d5e0b650f1eef2ef54d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 28 Aug 2026 21:37:38 +0800 Subject: [PATCH 049/429] fix(snapshot): bind fixture servers to OS-assigned ports --- ...8-24-session-log-snapshot-corpus.i18n.yaml | 4 +-- .../2026-08-24-session-log-snapshot-corpus.md | 9 ++++- ...26-08-24-session-log-snapshot-corpus.zh.md | 9 ++++- snapshots/session/headless.snapshot.ts | 13 +++++++ .../web-fetch/web-fetch-fixture-server.mjs | 34 +++++++++++++------ .../cordis.snapshot.yml | 1 + .../web-search-endpoint-guidance/cordis.yml | 1 + .../web-search-error-fixture.mjs | 18 ++++++++-- 8 files changed, 72 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml index 21d01904da..359d784438 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.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-24-session-log-snapshot-corpus.md -2026-08-24-session-log-snapshot-corpus.md: 328f0346554158d531dbda4b31a28277e37cc6dc -2026-08-24-session-log-snapshot-corpus.zh.md: 374ea2a1939e6e063f348e21fb74642371c345ac +2026-08-24-session-log-snapshot-corpus.md: 8b2f98e0a691e3085ff2286af3183048209f7ab8 +2026-08-24-session-log-snapshot-corpus.zh.md: 19952f05c4803d50a6e3c7c987cadd9482d5e87b diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md index 328f034655..8b2f98e0a6 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md @@ -18,6 +18,8 @@ This decision supersedes the ACP-specific placement and controller ownership in The recorded session remains the primary input and expected output. Human-originated messages drive the selected public interface, recorded assistant chunks drive deterministic model replay, and the normalized persisted result must equal the fixture. Parent and child sessions share one typed redaction map. Committed fixtures contain relationship-preserving identity tokens and replace request system prompts and tool schemas with tokens; each distinct header class retains one explicit sidecar owner. +Scenario-owned HTTP fixtures separate the stable authority recorded in the session from their transport listener. Each fixture binds loopback port `0`, lets the operating system allocate and bind the port atomically, and maps the recorded URL or endpoint through the real provider to that listener. Any process-global transport interception matches only the recorded endpoint, is owned by the fixture fiber, and is restored before the listener closes. + Every existing ACP scenario receives a behavior-preserving destination. Ordinary one-shot behavior uses the headless profile, persistent machine control uses the SDK profile, and only ACP protocol behavior remains ACP-owned. Web scenarios driven by a recorded session join the corpus and retain their ARIA or geometry expected output as secondary evidence. Web and package tests without a recorded-session source keep owner-local expected output and stop using snapshot paths or filenames. Workspace inputs remain scenario-local. A mutating scenario compares a complete expected final workspace that record and refresh never rewrite, so a model or tool self-report cannot satisfy the test. Existing intentional session reuse remains an explicit acyclic owner reference; the corpus adds no workspace inheritance or general fixture-merging mechanism. @@ -34,6 +36,10 @@ Workspace inputs remain scenario-local. A mutating scenario compares a complete **Deduplicate workspaces and recorded sessions automatically.** The current workspace duplication is small and intentional locality is easier to review. Only existing semantic session reuse justifies an explicit reference. +**Bind the recorded URL's numeric port.** A stable listener port keeps transport and transcript values identical, but concurrent snapshot jobs on one host share the network namespace and race for that port. + +**Probe an unused port before launching the scenario.** Releasing a probed port before the child binds it creates a time-of-check/time-of-use race. Binding port `0` inside the owning process keeps allocation and ownership atomic. + ## Invariants - Every existing recorded-session scenario has one passing replacement before its old owner is removed. @@ -43,11 +49,12 @@ Workspace inputs remain scenario-local. A mutating scenario compares a complete - Mutating scenarios verify their final workspace externally. - Owner-local process expectations use `*.expected.e2e.ts` and a separate built-output gate. - Source and built adapters install replay-only packages in isolated profile fallbacks; distinct prompt-section orders keep their request headers byte-identical. +- Scenario HTTP fixtures bind OS-assigned loopback ports while preserving their recorded model-visible authorities. - Source and built launch modes, browser replay, SDK projections, packaged Python runtime cases, documentation gates, and repository hygiene pass. ## Consequences -The corpus makes controller ownership visible: ordinary Agent behavior no longer inherits ACP protocol output, SDK and Web projections retain their interface-specific evidence, and only ACP cancellation and permission exchanges remain ACP-owned. Contributors review one normalized session diff plus the sidecars or UI expectations that add independent evidence. Adding a composition requires a manifest class pin; adding a volatile identity requires a typed relationship-preserving redaction rule rather than a broader text scrubber. +The corpus makes controller ownership visible: ordinary Agent behavior no longer inherits ACP protocol output, SDK and Web projections retain their interface-specific evidence, and only ACP cancellation and permission exchanges remain ACP-owned. Contributors review one normalized session diff plus the sidecars or UI expectations that add independent evidence. Adding a composition requires a manifest class pin; adding a volatile identity requires a typed relationship-preserving redaction rule rather than a broader text scrubber. Concurrent jobs can replay network-backed fixtures without reserving repository-wide ports, at the cost of a fixture-local mapping between the recorded authority and its transport listener. ## Risks diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md index 374ea2a193..19952f05c4 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md @@ -18,6 +18,8 @@ Status: implemented 录制会话仍是主要输入和预期输出。来自用户的消息驱动所选公开接口,录制的 assistant chunk 驱动确定性模型回放,规范化后的持久化结果必须等于 fixture。父会话和子会话共享同一类型化脱敏映射。提交的 fixture 使用保留关系的身份 token,并将请求 system prompt 和工具 schema 替换为 token;每个不同 header 类仍保留一个显式 sidecar 所有者。 +场景拥有的 HTTP fixture 将会话中录制的稳定 authority 与传输 listener 分离。每个 fixture 在回环地址上绑定端口 `0`,由操作系统以一次原子操作分配并绑定端口,再将录制的 URL 或 endpoint 通过真实 provider 映射到该 listener。任何进程全局传输拦截只匹配录制 endpoint,由 fixture fiber 拥有,并在关闭 listener 前恢复。 + 每个现有 ACP 场景都获得一个保留行为的目标。普通单次行为使用 headless profile,需要持久机器控制的行为使用 SDK profile,只有 ACP 协议行为继续归 ACP 所有。由录制会话驱动的 Web 场景加入该语料,并保留其 ARIA 或几何预期输出作为辅助证据。没有录制会话来源的 Web 和包级测试保留归属方本地的预期输出,并停止使用快照路径或文件名。 Workspace 输入继续归各场景本地所有。变更文件的场景比较完整的预期最终 workspace,record 与 refresh 绝不改写该预期,因此模型或工具的自报结果无法满足测试。现有的有意会话复用继续使用显式、无环的所有者引用;语料不增加 workspace 继承或通用 fixture 合并机制。 @@ -34,6 +36,10 @@ Workspace 输入继续归各场景本地所有。变更文件的场景比较完 **自动去重 workspace 和录制会话。** 当前 workspace 重复很少,有意保持本地性更易审查。只有现有的语义会话复用值得显式引用。 +**直接绑定录制 URL 的数值端口。** 稳定 listener 端口使传输值与 transcript 值一致,但同一主机上的并发快照 job 共享网络命名空间,会争用该端口。 + +**在启动场景前探测未使用端口。** 子进程绑定前释放已探测端口会产生检查时间与使用时间竞态。在拥有该端口的进程内绑定端口 `0`,可使分配与所有权保持原子性。 + ## Invariants - 每个现有录制会话场景都在移除旧所有者之前拥有一个通过的替代场景。 @@ -43,11 +49,12 @@ Workspace 输入继续归各场景本地所有。变更文件的场景比较完 - 变更内容的场景从外部验证最终 workspace。 - 所属位置的进程预期使用 `*.expected.e2e.ts`,并由单独的构建产物门禁运行。 - 源码与构建适配器在隔离的 profile fallback 中安装仅回放包;不同的提示词 section 顺序值使两种模式的请求 header 保持字节一致。 +- 场景 HTTP fixture 绑定由操作系统分配的回环端口,同时保留录制的模型可见 authority。 - 源码和构建启动模式、浏览器回放、SDK 投影、打包 Python 运行时场景、文档门禁和仓库卫生检查通过。 ## Consequences -该语料让控制器所有权可见:普通 Agent 行为不再继承 ACP 协议输出,SDK 和 Web 投影保留各自接口专有的证据,只有 ACP 取消与权限交换仍归 ACP 所有。贡献者审查一份规范化会话差异,以及提供独立证据的 sidecar 或 UI 预期。新增组合必须提供 manifest 类别 pin;新增易变身份必须添加保留关系的带类型脱敏规则,而不是扩大文本清洗范围。 +该语料让控制器所有权可见:普通 Agent 行为不再继承 ACP 协议输出,SDK 和 Web 投影保留各自接口专有的证据,只有 ACP 取消与权限交换仍归 ACP 所有。贡献者审查一份规范化会话差异,以及提供独立证据的 sidecar 或 UI 预期。新增组合必须提供 manifest 类别 pin;新增易变身份必须添加保留关系的带类型脱敏规则,而不是扩大文本清洗范围。并发 job 可以回放依赖网络的 fixture,而无需预留仓库级端口,代价是 fixture 内需要维护录制 authority 与传输 listener 的映射。 ## Risks diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 2473ccd97d..1579c5d04e 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -507,6 +507,19 @@ describe('headless recorded-session snapshots', () => { } }) + it('binds scenario HTTP fixtures only to OS-assigned ports', async () => { + for (const scenario of scenarios) { + const fixtureNames = (await readdir(scenario.dir)).filter(name => name.endsWith('.mjs')) + for (const fixtureName of fixtureNames) { + const source = await readFile(join(scenario.dir, fixtureName), 'utf8') + const boundPorts = [...source.matchAll(/\.listen\(\s*([^,\s)]+)/gu)].map(match => match[1]) + for (const port of boundPorts) { + expect(port, `${scenario.name}/${fixtureName}: listener port`).toBe('0') + } + } + } + }) + it('stores session-owned inputs with typed redaction and no ACP transcript', async () => { for (const scenario of scenarios) { const fixtures = await fixtureSessions(scenario) diff --git a/snapshots/session/web-fetch/web-fetch-fixture-server.mjs b/snapshots/session/web-fetch/web-fetch-fixture-server.mjs index d9acba8b3f..f0b8db6ebb 100644 --- a/snapshots/session/web-fetch/web-fetch-fixture-server.mjs +++ b/snapshots/session/web-fetch/web-fetch-fixture-server.mjs @@ -1,15 +1,15 @@ /** * Deterministic HTTP provider for the web-fetch snapshot scenario: a small * HTML page (headings, named entities, a GFM table, nested formatting) on a - * fixed loopback port behind the real address-pinned transport. Recording and - * replay therefore exercise fetch and markdown rendering without - * external network. The port is fixed because the fetched URL is recorded. + * OS-assigned loopback port behind the real address-pinned transport. Recording + * and replay therefore exercise fetch and markdown rendering without external + * network while retaining the recorded request URL. */ import { createServer } from 'node:http' import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http' -/** Fixed loopback port the scenario prompt points `web_fetch` at. */ -const PORT = 43117 +/** Model-visible URL retained by the recorded session. */ +const RECORDED_URL = 'http://public.test:43117/menu.html' const PAGE = ` Menu @@ -40,7 +40,7 @@ const LIMITS = { * Register the deterministic provider and start its loopback server. * @param ctx - Cordis context; the effect disposes the server with the fiber. */ -export function apply(ctx) { +export async function apply(ctx) { const server = createServer((req, res) => { if (req.url === '/menu.html') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) @@ -52,14 +52,19 @@ export function apply(ctx) { }) const listening = new Promise((resolve, reject) => { server.once('error', reject) - server.listen(PORT, '127.0.0.1', () => resolve(undefined)) + server.listen(0, '127.0.0.1', () => resolve(undefined)) }) - void listening.catch(() => undefined) + await listening + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('web-fetch-fixture-server: loopback listener has no TCP address') + } + const transportUrl = new URL(RECORDED_URL) + transportUrl.port = String(address.port) // The fixture must never hold the process open past protocol shutdown. server.unref() const resolveAddresses = async (hostname) => { - await listening if (hostname !== 'public.test') throw new Error(`unexpected snapshot hostname: ${hostname}`) return [{ address: '127.0.0.1', family: 4 }] } @@ -71,5 +76,14 @@ export function apply(ctx) { server.closeAllConnections() }) }, 'web-fetch-fixture-server') - ctx.web.registerFetchProvider(new HttpFetchProvider(LIMITS, resolveAddresses)) + const provider = new HttpFetchProvider(LIMITS, resolveAddresses) + ctx.web.registerFetchProvider({ + id: provider.id, + available: () => provider.available(), + fetch: async (request, signal) => { + if (request.url !== RECORDED_URL) throw new Error(`unexpected snapshot URL: ${request.url}`) + const result = await provider.fetch({ url: transportUrl.toString() }, signal) + return { ...result, url: RECORDED_URL } + }, + }) } diff --git a/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml b/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml index ffd6cf97f8..6062521ff8 100644 --- a/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml +++ b/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml @@ -29,4 +29,5 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKey: snapshot-key + # The fixture maps this recorded authority to its OS-assigned listener. baseURL: http://127.0.0.1:43118/anthropic/v1 diff --git a/snapshots/session/web-search-endpoint-guidance/cordis.yml b/snapshots/session/web-search-endpoint-guidance/cordis.yml index 222db9ec16..13f1776aca 100644 --- a/snapshots/session/web-search-endpoint-guidance/cordis.yml +++ b/snapshots/session/web-search-endpoint-guidance/cordis.yml @@ -7,4 +7,5 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKey: snapshot-key + # The fixture maps this recorded authority to its OS-assigned listener. baseURL: http://127.0.0.1:43118/anthropic/v1 diff --git a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs index 291ef07715..314f9b2dee 100644 --- a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs +++ b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs @@ -1,8 +1,8 @@ /** Deterministic authentication failure for the search endpoint guidance snapshot. */ import { createServer } from 'node:http' -/** Fixed loopback port recorded in the provider diagnostic. */ -const PORT = 43118 +/** Model-visible endpoint retained by the recorded session. */ +const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' /** Cordis plugin name. */ export const name = 'web-search-error-fixture' @@ -20,10 +20,22 @@ export async function apply(ctx) { }) await new Promise((resolve, reject) => { server.once('error', reject) - server.listen(PORT, '127.0.0.1', () => resolve(undefined)) + server.listen(0, '127.0.0.1', () => resolve(undefined)) }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('web-search-error-fixture: loopback listener has no TCP address') + } + const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` + const originalFetch = globalThis.fetch + const fixtureFetch = (input, init) => originalFetch( + typeof input === 'string' && input === RECORDED_ENDPOINT ? transportEndpoint : input, + init, + ) + globalThis.fetch = fixtureFetch server.unref() ctx.effect(() => async () => { + globalThis.fetch = originalFetch await new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve(undefined)) server.closeAllConnections() From 94c714d813d5ce3a71060ef0adda78136f6032bf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 28 Aug 2026 22:01:40 +0800 Subject: [PATCH 050/429] docs(testing): add CI test reliability skill --- ...-08-28-ci-test-reliability-skill.i18n.yaml | 6 + .../2026-08-28-ci-test-reliability-skill.md | 41 +++++++ ...2026-08-28-ci-test-reliability-skill.zh.md | 41 +++++++ ...deterministic-and-stress-testing.i18n.yaml | 4 +- ...-06-11-deterministic-and-stress-testing.md | 2 + ...-11-deterministic-and-stress-testing.zh.md | 2 + .../skills/dsh-ci-test-reliability/SKILL.md | 109 ++++++++++++++++++ .../references/ci-flake-diagnosis.md | 58 ++++++++++ .agents/skills/dsh-code-review/SKILL.md | 3 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 2 + 10 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md create mode 100644 .agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md create mode 100644 .agents/skills/dsh-ci-test-reliability/SKILL.md create mode 100644 .agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md diff --git a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.i18n.yaml b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.i18n.yaml new file mode 100644 index 0000000000..473728433a --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.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-28-ci-test-reliability-skill.md +2026-08-28-ci-test-reliability-skill.md: 5edf2aa769047cb5d4052a1338f2cc3886d9e0f9 +2026-08-28-ci-test-reliability-skill.zh.md: fd1a6d584f3c4686761f8c94ba99ee0d1c9b0233 diff --git a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md new file mode 100644 index 0000000000..5edf2aa769 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md @@ -0,0 +1,41 @@ +# Agent Note: CI test reliability skill + +Status: implemented + +English | [中文](2026-08-28-ci-test-reliability-skill.zh.md) + +## Problem + +DeepSeek Harness runs tests across concurrent Vitest files, worker processes, repository gates, and Actions jobs. Process isolation does not isolate host ports, predictable paths, external namespaces, or inherited children, while process-global mutations and incomplete teardown can contaminate later tests. A test can select the correct tier and still pass only when it runs alone. + +The testing policy owns test tiers, defensive patterns own runtime lifecycle rules, pre-push guidance selects commands, and code review evaluates completed diffs. None of them gives an agent a focused workflow for designing resource-owning tests against the real CI topology or classifying an existing probabilistic failure before changing code. + +## Decision + +[dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) owns test isolation and CI-flake diagnosis guidance. It applies when tests or fixtures acquire host resources, mutate process-global state, depend on asynchronous readiness, own subprocesses or network listeners, or exhibit probabilistic CI failures. + +The skill requires agents to model concurrency beyond one Vitest process, allocate live resources atomically, separate stable fixture identities from ephemeral transport addresses, synchronize on observable state, restore global mutations exactly, and await teardown to quiescence. Regression evidence matches the owned risk: negative controls for guards, deterministic barriers for races, concurrent independent processes for host-resource isolation, and external observations instead of component self-reports. + +The diagnosis-only workflow lives in a separate reference so ordinary authoring does not load Actions triage procedure. It compares passing and failing evidence before classifying host collisions, incomplete lifecycle, global contamination, load-sensitive synchronization, platform or entry-path failures, product races, provider transience, or runner infrastructure. + +[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) conditionally consults the reliability skill before selecting commands, while [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) applies it when reviewing risky tests. Command selection and general PR review remain with those existing skills. + +This decision partially overlaps the [deterministic and stress testing proposal](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md). The skill ships authoring and diagnosis guidance; it does not implement that proposal's lint rule, universal replay fixture, or nightly stress job, so the proposal remains active. + +## Alternatives considered + +**Expand dsh-pre-push-checks.** Pre-push guidance runs after test design and owns evidence selection. Making it also own resource allocation, synchronization, teardown, and CI diagnosis would mix two different decisions and load reliability procedure for ordinary pushes. + +**Expand dsh-code-review.** Review guidance can detect unreliable tests after a diff exists, but it cannot guide the agent while the fixture is being designed or while a failure is being diagnosed without a PR. + +**Put the complete workflow in the standing testing policy.** The testing policy must remain the concise authority for tiers and placement. Loading detailed Actions diagnosis and resource-specific procedure for every test task would duplicate situational guidance and make that policy harder to scan. + +**Add a generic stress runner or regex gate immediately.** Repeated green runs do not prove a race is controlled, and literal ports, paths, sleeps, and URLs can be valid parser inputs or expected values. A later high-signal defect class can justify a narrow executed check without making broad textual matches policy. + +## Consequences + +Agents receive the reliability rules while designing or diagnosing the tests that need them, and pre-push and review workflows share the same criteria without duplicating the procedure. Pure deterministic tests continue to use the normal focused evidence path. + +The skill is advisory, so it cannot mechanically prevent every resource collision. A repeated, statically identifiable defect can still justify an executed repository check. The repository also retains one additional active Skill and reference whose links and statements must remain current with the actual CI topology. + +The existing deterministic-and-stress proposal remains open, and this change does not audit or rewrite the current test corpus. diff --git a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md new file mode 100644 index 0000000000..fd1a6d584f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md @@ -0,0 +1,41 @@ +# Agent Note: CI 测试可靠性 Skill + +Status: implemented + +[English](2026-08-28-ci-test-reliability-skill.md) | 中文 + +## 问题 + +DeepSeek Harness 会在并发的 Vitest 文件、worker 进程、仓库 gate 与 Actions job 中运行测试。进程隔离不会隔离宿主机端口、可预测路径、外部命名空间或继承的子进程,而进程全局状态变更与未完成的 teardown 可能污染后续测试。即使测试选择了正确层级,也可能只在独占运行时通过。 + +测试政策负责测试层级,防御性模式负责运行时生命周期规则,pre-push 指引负责选择命令,代码 review 负责检查已完成的 diff。它们都没有为 agent 提供一个聚焦流程,用于按照真实 CI 拓扑设计会占用资源的测试,或在修改代码前对已有概率性失败进行分类。 + +## 决策 + +[dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) 负责测试隔离与 CI 概率性失败诊断指引。测试或 fixture 占用宿主机资源、修改进程全局状态、依赖异步就绪、持有子进程或网络 listener,或出现概率性 CI 失败时,使用该 Skill。 + +该 Skill 要求 agent 建模单个 Vitest 进程之外的并发,原子分配实时资源,把稳定 fixture 标识与临时传输地址分开,按可观察状态同步,精确恢复全局变更,并等待 teardown 达到静止状态。回归证据与所持有的风险匹配:guard 使用负向控制,竞态使用确定性 barrier,宿主机资源隔离使用并发独立进程,并以外部观察代替组件自述。 + +仅用于诊断的流程放在单独 reference 中,因此普通编写任务不会加载 Actions 分诊步骤。它会先比较成功与失败证据,再对宿主机冲突、未完成生命周期、全局状态污染、负载敏感同步、平台或入口路径失败、产品竞态、provider 瞬时故障或 runner 基础设施进行分类。 + +[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 在选择命令前按条件引用可靠性 Skill,[dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 则在 review 高风险测试时应用它。命令选择与通用 PR review 仍由这些现有 Skill 负责。 + +该决策与[确定性与压力测试提案](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md)部分重合。该 Skill 交付测试编写与诊断指引,但没有实现提案中的 lint 规则、通用回放 fixture 或 nightly stress job,因此提案保持活跃。 + +## 考虑过的替代方案 + +**扩展 dsh-pre-push-checks。** Pre-push 指引在测试设计之后运行,负责选择证据。如果它还负责资源分配、同步、teardown 与 CI 诊断,就会混合两种不同决策,并让普通 push 也加载可靠性流程。 + +**扩展 dsh-code-review。** Review 指引可以在 diff 已存在后发现不可靠测试,但无法在 fixture 设计过程中指导 agent,也无法在没有 PR 时指导故障诊断。 + +**把完整流程放入常驻测试政策。** 测试政策需要保持为测试层级与放置规则的简洁权威来源。让每个测试任务都加载详细 Actions 诊断与资源专项流程,会重复情境性指引,也会降低政策的可扫描性。 + +**立即增加通用 stress runner 或正则 gate。** 重复运行保持绿色不能证明竞态已受控,而字面端口、路径、sleep 与 URL 可能是合法的 parser 输入或期望值。未来若出现高信号缺陷类型,可以增加窄范围的可执行检查,而不必把宽泛文本匹配当成政策。 + +## 后果 + +Agent 在设计或诊断确实需要这些规则的测试时获得可靠性指引,pre-push 与 review 流程也能共用同一套标准而不复制步骤。纯确定性测试继续采用普通的聚焦证据路径。 + +该 Skill 属于指导性规则,无法机械阻止所有资源冲突。如果某种缺陷反复出现且能被静态识别,仍可增加可执行的仓库检查。仓库也会多维护一个活跃 Skill 与 reference,其链接和陈述必须与真实 CI 拓扑保持一致。 + +现有确定性与压力测试提案继续开放,本变更也不会审计或重写当前测试语料库。 diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml index 66b0331800..ca7d2c9317 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.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/proposed/testing/2026-06-11-deterministic-and-stress-testing.md -2026-06-11-deterministic-and-stress-testing.md: d9977be835af05f9ee303b63ec6015bc9e153170 -2026-06-11-deterministic-and-stress-testing.zh.md: 263e69f85a1cd8ee47da07210e513cab272d1a44 +2026-06-11-deterministic-and-stress-testing.md: fd69611a393e36df5c5707640175bfd85c8e77ea +2026-06-11-deterministic-and-stress-testing.zh.md: 5ba8dbe4cf2a6c08bf1a3d68def9f801430b176a diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md index d9977be835..fd69611a39 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.md @@ -4,6 +4,8 @@ Status: proposed English | [中文](2026-06-11-deterministic-and-stress-testing.zh.md) +The [CI test reliability skill](../../implemented/testing/2026-08-28-ci-test-reliability-skill.md) provides current authoring and diagnosis guidance without implementing the lint rule, universal replay fixture, or nightly stress job proposed here. Those mechanisms remain proposed. + ## Problem Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt that wastes agent cycles on retries and can mask ordering bugs. Separately, our core architectural promise (any session log replays to identical derived history) is asserted in two tests but is cheap to assert *everywhere*. And the inbox wakeup race was verified by hand exactly once; nothing re-verifies it continuously. diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md index 263e69f85a..5ba8dbe4cf 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -4,6 +4,8 @@ Status: proposed [English](2026-06-11-deterministic-and-stress-testing.md) | 中文 +[CI 测试可靠性 Skill](../../implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md) 提供当前的测试编写与诊断指引,但没有实现本提案中的 lint 规则、通用回放 fixture 或 nightly stress job。这些机制仍处于提案状态。 + ## 问题 若干 agent loop(智能体循环)测试通过 `setTimeout(30)` 睡眠来同步——这是一笔不稳定性债务,浪费 agent 的重试周期,还可能掩盖时序 bug。另外,我们的核心架构承诺(任何会话日志回放后都能得到相同的派生历史)目前只在两个测试中断言,但在*所有*测试中断言的成本极低。此外,inbox 唤醒竞态只被手动验证过一次,没有任何机制持续复验。 diff --git a/.agents/skills/dsh-ci-test-reliability/SKILL.md b/.agents/skills/dsh-ci-test-reliability/SKILL.md new file mode 100644 index 0000000000..7483f13fb0 --- /dev/null +++ b/.agents/skills/dsh-ci-test-reliability/SKILL.md @@ -0,0 +1,109 @@ +--- +name: dsh-ci-test-reliability +description: Design, review, and diagnose DeepSeek Harness tests and fixtures that can fail nondeterministically under CI concurrency, shared host resources, clocks, process-global state, subprocesses, network listeners, or asynchronous teardown. Use when adding or changing tests with those risks, investigating flaky CI, or reviewing test isolation; use dsh-pre-push-checks separately to select outgoing commands. +--- + +# Reliable DSH CI tests + +Build tests that remain correct under the repository's real CI topology, not only when run alone on a quiet workstation. This skill owns isolation and reliability decisions; it does not replace the repository's test-tier policy or select every command for a push. + +## Read the owning rules + +- Use [the testing policy](../../../docs/testing.md) to select unit, coverage, expected-output, snapshot, browser, or real-API evidence. +- Use [the defensive patterns](../../../docs/defensive-patterns.md) for lifecycle, subprocess, cancellation, and teardown behavior. +- Read the active Vitest config and GitHub workflow when their worker or job topology affects the test. +- For recorded-session scenarios, also follow [the snapshot instructions](../../../snapshots/AGENTS.md). +- Use [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md) after the test design is sound to select outgoing validation. + +## Model the execution topology + +Assume these layers can overlap unless the active configuration proves otherwise: + +1. Tests in one Vitest file. +2. Separate Vitest files or worker processes. +3. Independent Vitest or repository-gate processes in one job. +4. Different Actions jobs whose runners share one host. + +Process isolation does not isolate host ports, predictable filesystem paths, external services, databases, sockets, or inherited child processes. For every acquired resource, identify its owner, atomic allocation mechanism, observable readiness signal, registered cleanup, and quiescent completion signal. + +Do not serialize an entire suite merely because one fixture lacks isolation. Narrow the exclusive scope or change the resource allocation first. A sequential Vitest block cannot protect a host resource from another file, process, job, or runner. + +## Allocate resources atomically + +Use the resource owner's allocator instead of checking availability and claiming it later. + +- Network fixtures bind loopback with `listen(0)` and read the assigned address only after the server reports that it is listening. Never scan for a free port and bind it later. +- Create private per-test temporary roots with `mkdtemp`; do not acquire predictable shared paths. +- Give shared databases, sockets, sessions, and output locations unique per-test namespaces. +- Use exclusive creation where a path must not already exist. +- Keep stable recorded identifiers separate from ephemeral transport addresses. Translate inside the fixture instead of forcing the live resource to use the recorded value. + +Literal paths and URLs used only as parser inputs or expected values are not acquired resources. Do not rewrite them merely because they look fixed. + +## Contain process-global state + +Treat `process.env`, `cwd`, fake timers, locale and timezone, module mocks, registries, console hooks, `globalThis`, and global `fetch` interception as exclusive mutable resources. + +Prefer an injected dependency or instance-local adapter. When mutation is required: + +- capture whether the original value was absent or present; +- restore that exact state; +- register restoration immediately; +- use `try/finally` around the smallest mutation scope; +- keep an `afterEach` fallback when failure before the local `finally` is plausible; +- intercept the narrowest exact request or call that the fixture owns. + +## Synchronize on state + +A fixed sleep is not evidence that setup completed or cleanup settled. + +- Wait for an explicit readiness event, handshake, state transition, owned promise, or externally observable condition. +- Use deferred promises or barriers to place a race at a deterministic point and prove the relevant operations overlap. +- Use a timeout only to bound a wait, never as the condition that makes the assertion correct. +- Do not assert scheduler-dependent ordering unless that ordering is the product behavior under test. +- When time itself is the subject, inject or fake the clock and always restore real timers. + +## Dispose to quiescence + +Register cleanup immediately after acquisition so assertion failures also release the resource. Cleanup stops new callbacks or requests, detaches listeners, restores global hooks, terminates owned work, and awaits child exit, server close, worker termination, or the equivalent completion signal. + +Calling `abort()`, `close()`, or `kill()` without awaiting the owned completion signal is incomplete teardown. When late completion is possible, prove that disposal prevents it from mutating another test. + +## Prove the intended regression + +- Observe an ordinary regression fail before the fix when practical. +- For a new static or corpus guard, temporarily introduce the rejected case and observe the intended failure. +- For a race, use barriers to prove overlap; repeated execution alone is not a race test. +- For ports, sockets, shared paths, subprocesses, or other host resources, run independent test processes concurrently when cross-process isolation is part of the fix. +- Verify external state, events, files, logs, exits, or disposal instead of trusting the component's self-report. + +Stress runs supplement a deterministic regression; they do not replace one. + +## Reject flake-masking fixes + +Do not present these as root-cause fixes for deterministic local tests: + +- increasing a timeout without identifying the awaited state; +- adding retries; +- making all files serial; +- swallowing an error or unhandled rejection; +- weakening an assertion; +- normalizing away unstable behavior; +- adding a sleep before cleanup or assertion. + +Retries remain valid for documented transient external-provider tests under the real-API policy. Keep that exception at the external boundary. + +## Diagnose existing flakes + +For an existing probabilistic CI failure, read [the CI flake diagnosis workflow](references/ci-flake-diagnosis.md). A diagnosis-only request remains read-only: report the cause and evidence unless the user also asks for a fix. + +## Validate and report + +Run the smallest focused regression for the affected behavior. Add topology-specific evidence only when the change owns that risk: + +- global mutation needs restoration evidence; +- lifecycle or subprocess work needs quiescent teardown evidence; +- ports, sockets, or shared paths need concurrent independent-process evidence; +- a new guard needs a negative control. + +Before a push, use [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). Report exact commands and observed results; do not describe retries, skipped tests, or pending CI as passing. diff --git a/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md b/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md new file mode 100644 index 0000000000..101ad90cca --- /dev/null +++ b/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md @@ -0,0 +1,58 @@ +# CI flake diagnosis + +Use this workflow only when the task is to investigate an existing probabilistic test or CI failure. Preserve the requested read/write scope: diagnosis does not authorize a fix, workflow rerun, or CI configuration change. + +## Freeze the evidence + +Record the repository, workflow, job, commit SHA, runner labels, timestamps, exact failing test or command, and the first stable failure signature. Keep infrastructure messages separate from test output. + +Compare multiple failing and passing runs. Prefer runs of the same SHA; when that is impossible, verify that the relevant test and CI configuration are identical across the compared commits. One passing rerun does not prove an infrastructure fault, and one timeout does not prove a product race. + +Use Actions logs and metadata to establish whether failures overlap on one host or resource namespace. Preserve links to the supporting runs rather than pasting large logs. + +## Classify the failure + +Classify from recorded evidence, not from the eventual fix: + +- **Host-resource collision:** the same port, socket, database, predictable path, cache, or external namespace is acquired by independent processes or jobs. +- **Incomplete lifecycle:** teardown returns before children, workers, streams, servers, or callbacks reach quiescence; later output or mutations appear in another test. +- **Process-global contamination:** outcome depends on test order or leaked `process.env`, `cwd`, fake timers, globals, mocks, locale, or module state. +- **Load-sensitive synchronization:** a sleep, polling interval, or assumed event-loop turn substitutes for observable readiness or completion. +- **Platform or entry-path mismatch:** the failure consistently follows an operating system, shell, filesystem rule, source/build mode, or executable entry. +- **Product concurrency defect:** the test controls its resources, reproduces deterministically with explicit overlap, and exposes a race in shipped behavior. +- **External-provider transience:** the failure is owned by a live API or network boundary and matches its documented retry policy. +- **Runner infrastructure:** checkout, dependency download, disk, host process, or runner service fails independently of the test command. Require direct runner evidence before assigning this class. + +If evidence supports more than one independent fact, report each one. Do not collapse a timeout, signal, exit code, and assertion into a single inferred outcome. + +## Reproduce the smallest relevant topology + +Start with the owning test file or focused test name. Increase concurrency only to the first topology that reproduces the signature: + +1. one test process; +2. concurrent tests or files; +3. multiple independent Vitest processes; +4. the owning repository gate with its configured worker count; +5. separate jobs or runner processes sharing the implicated host resource. + +Match the active Vitest config, environment knobs, source/build mode, and platform. Do not lower a production timeout or add random load merely to manufacture a different failure. + +For a suspected race, replace probabilistic timing with a barrier at the contested transition. For a suspected host collision, prove simultaneous acquisition of the same identifier or prove that atomic unique allocation removes the conflict. + +## Fix at the owner + +When implementation is authorized, fix the component that allocates, publishes readiness, mutates global state, or owns teardown. Do not hide the failure in a snapshot normalizer, retry wrapper, broader timeout, global serialization setting, or weaker assertion. + +Keep stable fixture data separate from live resource allocation. A recorded URL can remain stable while the fixture maps its transport to an OS-assigned port; a stable expected path can remain an assertion without becoming a shared writable directory. + +## Close the investigation + +The evidence is complete when: + +- the original signature has a supported classification; +- the smallest relevant topology reproduces it, or the external evidence is sufficient and the reproduction limit is explicit; +- an authorized fix fails under a negative control or pre-fix state and passes under the same topology afterward; +- any concurrent-process, restoration, or quiescent-teardown proof required by the resource owner passes; +- remaining Actions checks are reported as passing, pending, skipped, or failing from their observed state. + +Do not run until a test happens to pass and call that result stable. Stop after the selected evidence establishes the conclusion, or report the missing fact that blocks classification. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 510e01f3d5..a18ac20d4e 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -13,6 +13,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. +- [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates Agent Note](../../notes/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. - For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation. @@ -39,7 +40,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Borrowed and derived state:** determine whether each retained value is borrowed or owned under the package contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. - **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. - **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch invalid Loader exports; a function plugin must named-export its namespace and have no default export. -- **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. +- **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. For resource-owning, asynchronous, or flaky tests, apply [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) to the real worker/job topology, allocation, restoration, synchronization, and quiescent teardown. Coverage is necessary but not evidence that the scenario is correct. - **Invariant lifecycle and negative controls:** verify candidate observations are rejected before publication where possible, session-backed checks reconstruct durable history after late loading or HMR, and a deliberately invalid case fails through the real runner for the intended rule. - **Implemented Agent Notes match shipped reality:** when a PR implements a proposed Agent Note, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. - **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review expected-output diffs as behavior changes, not formatting noise. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index a9902a45d7..9a687e8f9f 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -28,6 +28,8 @@ The command never guesses or fetches a base. Supply the ref verified from curren There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches. +When the outgoing change adds or changes a resource-owning or asynchronous test, fixture, helper, or CI execution path, use [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) first to decide whether restoration, negative-control, quiescent-teardown, or concurrent-process evidence applies. This skill still selects the commands and avoids repeating evidence that already passed. + - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. From 804b1ffbfcefa87f42ca2ed001332403bac5dc0a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:17:27 +0800 Subject: [PATCH 051/429] refactor(api): converge the Remote failure vocabulary and client surface Single RemoteError with a merge-extensible, domain-prefixed code map; owners throw at the failure point; streams surface marked failures; clients consume ctx.remote directly with isRemoteFailure as the only discrimination point and construct no failure instances. --- apps/web/tests/default-model.e2e.ts | 2 +- packages/api/gateway/package.json | 3 + packages/api/gateway/src/client/index.ts | 54 +++- .../api/gateway/src/client/journal-stream.ts | 38 +-- .../api/gateway/src/client/snapshot-stream.ts | 10 +- .../api/gateway/src/client/stream-client.ts | 22 +- packages/api/gateway/src/index.ts | 130 +++++----- .../api/gateway/src/remote-error-codes.ts | 35 +++ packages/api/gateway/src/types.ts | 34 +-- .../gateway/tests/gateway-stream.host.spec.ts | 27 +- .../api/gateway/tests/gateway.client.spec.ts | 40 +-- .../api/gateway/tests/gateway.host.spec.ts | 94 +++---- packages/api/gateway/tsconfig.client.json | 1 + packages/api/gateway/tsconfig.host.json | 1 + packages/api/remotes/src/client/index.ts | 27 +- packages/api/session-controller/package.json | 2 + packages/api/session-controller/src/agent.ts | 43 ++-- .../src/client/contract/result.ts | 29 --- .../src/client/contract/session.ts | 11 +- .../src/client/contract/sessions.ts | 4 +- .../src/client/contract/snapshot.ts | 6 +- .../session-controller/src/client/index.ts | 9 +- .../src/client/sessions/manager.ts | 141 +++++------ .../src/client/sessions/service.ts | 8 +- .../src/client/sessions/session.ts | 189 +++++--------- .../src/client/transport.ts | 27 +- .../api/session-controller/src/commands.ts | 143 +++++------ .../api/session-controller/src/history.ts | 36 ++- packages/api/session-controller/src/index.ts | 30 +-- packages/api/session-controller/src/list.ts | 22 +- .../session-controller/src/skill-catalog.ts | 33 +-- packages/api/session-controller/src/types.ts | 76 +++--- .../tests/agent.host.spec.ts | 13 +- .../tests/client-apply.client.spec.ts | 4 +- .../tests/client-contract.client.spec.ts | 11 - .../tests/commands-create-fork.host.spec.ts | 39 +-- .../commands-queue-attachment.host.spec.ts | 22 +- .../tests/controller.host.spec.ts | 3 +- .../tests/fake-api.client.ts | 113 ++++----- .../tests/manager.client.spec.ts | 89 +++---- .../tests/session-cold.host.spec.ts | 35 ++- .../tests/session-fork.host.spec.ts | 4 +- .../tests/session-models.host.spec.ts | 42 ++-- .../session-open-workspace-path.host.spec.ts | 14 +- ...session-pending-submissions.client.spec.ts | 5 +- .../tests/session-presets.host.spec.ts | 17 +- .../tests/session-rename.host.spec.ts | 6 +- .../tests/session-search.host.spec.ts | 36 +-- .../tests/session-skills.host.spec.ts | 14 +- .../tests/session.client.spec.ts | 121 +++++---- .../tests/sessions-service.client.spec.ts | 49 ++-- .../session-controller/tests/test-remote.ts | 18 +- .../tests/transport.client.spec.ts | 19 +- .../tests/transport.host.spec.ts | 32 +-- .../api/session-controller/tsconfig.host.json | 1 + .../settings-controller/src/credentials.ts | 40 ++- packages/api/settings-controller/src/index.ts | 150 ++++------- packages/api/settings-controller/src/types.ts | 58 ++--- .../tests/credentials-controller.host.spec.ts | 23 +- .../tests/settings-controller.host.spec.ts | 81 +++--- .../workspace-controller/src/client/index.ts | 15 +- .../workspace-controller/src/client/model.ts | 40 +-- .../src/client/service.ts | 2 +- .../api/workspace-controller/src/commands.ts | 40 ++- .../src/directory-picker.ts | 55 ++-- .../api/workspace-controller/src/types.ts | 61 ++--- .../tests/directory-picker.host.spec.ts | 29 +-- .../tests/model.client.spec.ts | 70 ++---- .../tests/transport.client.spec.ts | 51 ++-- .../tests/workspace-controller.host.spec.ts | 43 ++-- packages/client/connection/src/client/api.ts | 2 - .../client/connection/src/client/fixture.ts | 72 +++--- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/src/rpc-host.ts | 4 +- packages/client/connection/src/rpc.ts | 29 +-- .../tests/api-helpers.client.spec.ts | 2 +- .../tests/fixture-commands.client.spec.ts | 6 +- .../connection/tests/fixture.client.spec.ts | 46 ++-- .../connection/tests/node-half.host.spec.ts | 4 +- .../connection/tests/rpc-schema.host.spec.ts | 4 +- packages/client/locale/src/client/index.ts | 2 +- .../client/locale/tests/apply.client.spec.ts | 3 +- .../tests/document-language.client.spec.ts | 1 - .../locale/tests/invariant.client.spec.ts | 3 +- .../ui-agent-preset/src/client/index.ts | 7 +- .../ui-agent-preset/src/client/seat-store.ts | 49 ++-- .../src/client/section-store.ts | 106 ++++---- .../src/client/settings-store.ts | 83 +++---- .../tests/apply.client.spec.ts | 51 ++-- .../tests/section-store.client.spec.ts | 235 +++++++----------- .../tests/settings-store.client.spec.ts | 229 ++++++----------- .../tests/apply-inject.client.spec.tsx | 4 +- .../tests/chat-branch-tails.client.spec.tsx | 3 +- .../ui-chat/tests/chat-stats.client.spec.tsx | 3 +- .../ui-chat/tests/chat-view.client.spec.tsx | 5 +- .../tests/gate-branch-tails.client.spec.tsx | 3 +- .../client/ui-commands/src/client/service.ts | 10 +- .../ui-commands/tests/service.client.spec.ts | 14 +- .../src/client/image-labels.ts | 5 +- .../ui-conversation/src/client/input/hub.ts | 8 +- .../ui-conversation/src/client/service.ts | 2 +- .../src/client/skeleton/InputBar.tsx | 12 +- .../tests/apply-inject.client.spec.tsx | 5 +- .../tests/assembly-surfaces.client.spec.tsx | 11 +- .../tests/enter-behavior-row.client.spec.tsx | 3 +- .../tests/historical-images.client.spec.ts | 6 +- .../tests/input-bar.client.spec.tsx | 32 ++- .../service-orchestration.client.spec.ts | 30 +-- .../tests/skeleton.client.spec.tsx | 4 +- .../tests/todo-panel.client.spec.tsx | 3 +- .../ui-deliverables/src/client/index.ts | 8 +- .../tests/produced-files.client.spec.tsx | 29 ++- packages/client/ui-goal/src/client/index.ts | 2 +- packages/client/ui-goal/src/client/slots.ts | 13 +- .../tests/browser-plugin.client.spec.tsx | 14 +- .../ui-goal/tests/goalbar.client.spec.tsx | 18 +- .../src/client/controller.ts | 87 ++----- .../ui-message-feedback/src/client/index.ts | 4 +- .../ui-message-feedback/src/client/slots.ts | 2 +- .../tests/browser-plugin.client.spec.tsx | 2 +- .../tests/controller.client.spec.ts | 234 +++++++---------- .../message-feedback-actions.client.spec.tsx | 2 +- .../ui-model-selection/src/client/catalog.ts | 12 +- .../ui-model-selection/src/client/service.ts | 2 +- .../tests/catalog.client.spec.ts | 12 +- .../tests/model-select.client.spec.tsx | 4 +- .../ui-permission-presets/src/client/index.ts | 2 +- .../src/client/settings-store.ts | 31 ++- .../tests/browser-plugin.client.spec.ts | 13 +- .../permission-presets-row.client.spec.tsx | 14 +- .../tests/settings-store.client.spec.ts | 48 ++-- .../tests/browser-plugin.client.spec.ts | 5 +- .../tests/plan-mode-control.client.spec.tsx | 3 +- packages/client/ui-reference/package.json | 1 + .../client/ui-reference/src/client/index.ts | 18 +- .../tests/browser-plugin.client.spec.ts | 26 +- .../client/ui-settings-general/package.json | 3 - .../ui-settings-general/src/client/index.ts | 10 +- .../src/client/settings-document-store.ts | 22 +- .../tests/apply.client.spec.ts | 9 +- .../tests/components.client.spec.tsx | 18 +- .../settings-document-store.client.spec.ts | 51 ++-- .../tests/shell.client.spec.ts | 12 +- .../src/client/CustomProviderCard.tsx | 26 +- .../src/client/DeepSeekOnboardingDialog.tsx | 11 +- .../src/client/ModelListEditor.tsx | 14 +- .../src/client/ModelsSection.tsx | 57 ++--- .../src/client/ProviderEditor.tsx | 42 ++-- .../ui-settings-models/src/client/index.ts | 15 +- .../ui-settings-models/src/client/store.ts | 105 +++----- .../tests/apply.client.spec.ts | 7 +- .../tests/components.client.spec.tsx | 167 +++++++------ .../tests/onboarding-dialog.client.spec.tsx | 21 +- .../tests/provider-form.client.spec.tsx | 83 ++++--- .../tests/store.client.spec.ts | 103 +++----- .../tests/welcome-notice.client.spec.tsx | 18 +- .../tests/welcome-store.client.spec.ts | 18 +- .../client/ui-settings-plugins/package.json | 3 - .../ui-settings-plugins/src/client/index.ts | 6 +- ...ubagent-model-selection-card-controller.ts | 21 +- .../src/client/web-search-card-controller.ts | 30 +-- .../tests/apply.client.spec.ts | 13 +- .../tests/stores.client.spec.ts | 83 ++++--- packages/client/ui-settings/package.json | 5 +- .../client/ui-settings/src/client/index.ts | 25 +- .../ui-settings/src/client/settings-mirror.ts | 26 +- .../ui-settings/src/client/settings-scope.ts | 44 ++-- .../ui-settings/tests/plugin.client.spec.ts | 1 - .../tests/settings-mirror.client.spec.ts | 38 +-- .../tests/settings-scope.client.spec.ts | 66 ++--- packages/client/ui-skill/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 16 +- .../tests/browser-plugin.client.spec.ts | 1 - .../tests/conversation-ui.client.spec.tsx | 4 +- packages/client/ui-theme/src/client/index.ts | 2 +- .../ui-theme/tests/apply.client.spec.ts | 4 +- .../ui-theme/tests/invariant.client.spec.ts | 2 +- packages/client/ui-tool/src/client/apply.ts | 9 +- .../ui-tool/src/client/contract/slots.ts | 13 +- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 5 +- .../tests/assembly-surfaces.client.spec.tsx | 4 - .../tests/chat-code-subcalls.client.spec.tsx | 4 - .../tests/coverage-tails.client.spec.tsx | 3 +- .../ui-tool/tests/read-card.client.spec.tsx | 2 +- .../tests/tool-call-tree.client.spec.tsx | 7 +- .../tests/tool-details-render.client.tsx | 7 +- .../tests/toolview-slot.client.spec.tsx | 8 - .../ui-trajectory/tests/views.client.spec.tsx | 3 +- .../ui-workspace/src/client/contract/slots.ts | 8 +- .../client/ui-workspace/src/client/index.ts | 8 +- .../ui-workspace/src/client/navigation.ts | 3 +- .../src/client/rows/WorkspaceBrowser.tsx | 3 +- .../ui-workspace/tests/apply.client.spec.ts | 11 +- .../tests/rename-assembly.client.spec.tsx | 7 +- .../tests/workspace-browser.client.spec.tsx | 7 +- .../tests/workspace-picker.client.spec.tsx | 3 +- .../tests/workspaces-service.client.spec.ts | 13 +- packages/core/session/src/types.ts | 7 + .../src/client/TeamAction.tsx | 8 +- .../tests/browser-plugin.client.spec.ts | 11 +- .../tests/team-action.client.spec.tsx | 18 +- .../tests/transport/tunnel-client.spec.ts | 4 +- .../cordis-client-runner/package.json | 2 - .../src/client/inspect-registry.ts | 3 +- .../src/client/orchestrator.ts | 2 +- .../src/client/runtime.ts | 2 +- .../tests/orchestrator.client.spec.ts | 3 +- .../tests/plugin.client.spec.ts | 3 +- .../tests/runner.client.spec.ts | 3 +- .../cordis-client-runner/tsconfig.json | 3 - .../ui-cordis/src/client/CordisPanel.tsx | 2 +- .../ui-cordis/src/client/dynamic-port.ts | 2 +- .../ui-cordis/src/client/run-card-index.ts | 2 +- .../extensions/ui-cordis/src/client/slots.ts | 2 +- packages/llm/llm/src/index.ts | 15 +- packages/llm/llm/src/types.ts | 14 +- packages/llm/llm/tests/topology.spec.ts | 16 +- .../preset/agent-presets/src/authoring.ts | 77 +++--- packages/preset/agent-presets/src/index.ts | 152 ++++------- packages/preset/agent-presets/src/mount.ts | 11 +- packages/preset/agent-presets/src/preset.ts | 49 ---- packages/preset/agent-presets/src/types.ts | 34 +-- .../preset/agent-presets/tests/mount.spec.ts | 11 +- .../preset/agent-presets/tests/remote.spec.ts | 75 +++--- packages/subagent/subagent/package.json | 4 + .../subagent/subagent/src/control-types.ts | 62 +++-- packages/subagent/subagent/src/control.ts | 116 ++++----- packages/subagent/subagent/src/index.ts | 43 ++-- .../subagent/subagent/tests/control.spec.ts | 69 +++-- packages/subagent/subagent/tsconfig.json | 6 + .../test-support/client-runtime/package.json | 2 + .../test-support/client-runtime/src/index.ts | 2 +- .../test-support/client-runtime/src/remote.ts | 13 +- .../client-runtime/src/settings-remote.ts | 2 +- .../tests/remote.client.spec.ts | 2 +- .../test-support/client-runtime/tsconfig.json | 3 + packages/typert/protocol/src/index.ts | 41 +-- packages/typert/protocol/src/remote-error.ts | 46 ++++ packages/typert/protocol/src/types.ts | 28 ++- packages/util/time/package.json | 42 ++++ packages/util/time/src/index.ts | 33 +++ packages/util/time/src/invariant.ts | 30 +++ packages/util/time/tests/time.spec.ts | 29 +++ packages/util/time/tsconfig.json | 15 ++ packages/workspace/workspace/package.json | 2 + packages/workspace/workspace/src/types.ts | 8 + packages/workspace/workspace/tsconfig.json | 3 + pnpm-lock.yaml | 39 ++- tsconfig.base.json | 4 + tsconfig.host.json | 1 + 252 files changed, 3182 insertions(+), 3832 deletions(-) create mode 100644 packages/api/gateway/src/remote-error-codes.ts delete mode 100644 packages/api/session-controller/src/client/contract/result.ts create mode 100644 packages/typert/protocol/src/remote-error.ts create mode 100644 packages/util/time/package.json create mode 100644 packages/util/time/src/index.ts create mode 100644 packages/util/time/src/invariant.ts create mode 100644 packages/util/time/tests/time.spec.ts create mode 100644 packages/util/time/tsconfig.json diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index a041f85d14..bb3fe582d9 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -148,7 +148,7 @@ describe('web e2e: the composer model switch is the default for later sessions', sessionId: SessionId(await createSession('default-model-refusal')), mode: 'queue', content: [{ type: 'text', text: 'hi' }], - }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } }) + }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/model-unavailable' }) // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d5601ab188..e961defbad 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -35,6 +35,9 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-typert-protocol" + ], "inject": [ "@deepseek-ai/dsh-typert-registry", "@deepseek-ai/dsh-client-connection" diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 4fe46e6d2d..e52bcd0f31 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,6 +5,8 @@ */ import { Service } from '@deepseek-ai/cordis' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, @@ -13,6 +15,7 @@ import type { InvocationDescriptor, TypertClientEventListener, TypertClientRemote, + RemoteFailure, RemoteResult, TypertCodec, TypertDisposer, @@ -21,7 +24,6 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStreamCarrierError, - RemoteStreamError, RemoteStreamMuxClient, } from './stream-client.ts' import { ClientRemoteEvents } from './remote-events.ts' @@ -30,7 +32,7 @@ import { type RemoteStreamOptions, } from './remote-stream.ts' -export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts' +export { RemoteStreamCarrierError } from './stream-client.ts' export { RemoteJournalStream } from './journal-stream.ts' export type { RemoteJournalChange, @@ -104,6 +106,20 @@ export interface ClientRemote extends TypertClientRemote { * @returns a single-consumer stream annotated with physical generation ids. */ $stream(options: RemoteStreamOptions): RemoteStream + /** + * Fixed Host facts as plain reads: no store, no subscription, no generation + * counter. `home` stays undefined until the first ready frame and reflects + * the latest one afterwards. + */ + readonly $host: RemoteHostFacts +} + +/** The fixed Host facts exposed on `ctx.remote.$host`. */ +export interface RemoteHostFacts { + /** Host home directory from the ready frame, undefined before it. */ + readonly home: string | undefined + /** Whether the carrier connects to the local Host. */ + readonly isLoopback: boolean } declare module '@deepseek-ai/cordis' { @@ -166,6 +182,14 @@ class ClientRemoteService extends Service implements ClientRemote { return new RemoteStream(this.connection, options) } + get $host(): RemoteHostFacts { + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined ?? this.connection + return { + home: connection.generation.getSnapshot()?.host.home, + isLoopback: connection.isLoopback, + } + } + async $mount(contribution: TypertRemoteContribution): ReturnType { const callerCtx = this.ctx const owned = callerCtx.effect(async () => { @@ -410,7 +434,7 @@ class ClientRemoteService extends Service implements ClientRemote { try { const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) if (!mountActive(token)) return withdrawn(endpoint) - if (!result.ok) return { ok: false, error: result.error } + if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) } return { ok: true, value: result.value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly @@ -698,7 +722,27 @@ function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { - return { ok: false, error: { code: 'internal', message, details: {} } } + return { ok: false, error: new RemoteError('gateway/internal', message, {}) } +} + +/** + * Whether a caught value is a Remote failure this face delivered or threw. + * The one consumer-facing discrimination point: marked instances carry their + * Host code; anything else is a local fault the caller should let crash. + * @param error - a caught value. + * @returns true when the value narrows to RemoteFailure. + */ +export function isRemoteFailure(error: unknown): error is RemoteFailure { + return remoteErrorOf(error) !== undefined +} + +/** + * Rebuild the wire failure as a local RemoteError instance so the error branch + * carries a real Error and `throw result.error` keeps throw semantics. The wire + * is a validation boundary: codes outside the merged map surface as-is. + */ +function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure { + return new RemoteError(error.code as never, error.message, error.details as never) } type MarkedConnectionStreamFailure = Error & { @@ -715,7 +759,7 @@ async function *normalizeConnectionStream(source: AsyncIterable): Async if (!(error instanceof Error)) throw error const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure if (marker?.kind === 'remote') { - throw new RemoteStreamError(marker.code, error.message, marker.details) + throw new RemoteError(marker.code as never, error.message, marker.details as never) } if (marker?.kind === 'carrier') { throw new RemoteStreamCarrierError(error.message, { cause: error }) diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts index 7a6f2e8166..cde54083f6 100644 --- a/packages/api/gateway/src/client/journal-stream.ts +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -1,5 +1,6 @@ /** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStreamCarrierError } from './stream-client.ts' import type { RemoteStream, @@ -7,6 +8,11 @@ import type { RemoteStreamOptions, } from './remote-stream.ts' +/** Host-side stream protocol violation, marked so consumers surface it as an error state. */ +function protocolViolation(message: string): RemoteError<'gateway/internal'> { + return new RemoteError('gateway/internal', message, {}) +} + /** Transport-neutral opening snapshot or journal entry. */ export type RemoteJournalFrame = | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page } @@ -100,7 +106,7 @@ export abstract class RemoteJournalStream this.follow(this.initialRequest, signal), ended: accepted => accepted ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) - : new Error( + : protocolViolation( `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`, ), ...(options.carrierFailed === undefined @@ -153,7 +159,7 @@ export abstract class RemoteJournalStream 0) { - throw new Error(`${this.options.name} entry has an inverted cursor range`) + throw protocolViolation(`${this.options.name} entry has an inverted cursor range`) } return { first, last } } @@ -534,7 +540,7 @@ export abstract class RemoteJournalStream { + return new RemoteError('gateway/internal', message, {}) +} + /** Domain operations for one snapshot stream. */ export interface RemoteSnapshotStreamOptions { /** Diagnostic stream name used in protocol failures. */ @@ -69,7 +75,7 @@ export class RemoteSnapshotStream { } if (this.options.isSnapshot(item.value)) { if (snapshotSeen) { - throw new Error(`${this.options.name} emitted more than one opening snapshot`) + throw protocolViolation(`${this.options.name} emitted more than one opening snapshot`) } this.options.replace(item.value) snapshotSeen = true @@ -77,7 +83,7 @@ export class RemoteSnapshotStream { continue } if (!snapshotSeen) { - throw new Error(`${this.options.name} emitted an update before its opening snapshot`) + throw protocolViolation(`${this.options.name} emitted an update before its opening snapshot`) } this.options.update(item.value) } diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 0dd56cc72d..9616102cce 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -1,3 +1,4 @@ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' /** Browser owner for the Gateway multiplexed Remote stream socket. */ import { @@ -14,25 +15,6 @@ const RECONNECT_FACTOR = 2 const RECONNECT_MAX_MS = 10_000 /** One Host-reported Remote stream failure. */ -export class RemoteStreamError extends Error { - /** Stable carrier or Gateway error category. */ - readonly code: string - /** Host-provided structured failure context. */ - readonly details: object - - /** - * @param code - stable Gateway or business error category. - * @param message - Host-provided failure description. - * @param details - Host-provided structured failure context. - */ - constructor(code: string, message: string, details: object) { - super(message) - this.name = 'RemoteStreamError' - this.code = code - this.details = details - } -} - /** Physical Remote stream socket failure that may be retried by a domain transport. */ export class RemoteStreamCarrierError extends Error { /** @@ -105,7 +87,7 @@ export class RemoteStreamMuxClient { } terminal = true if (frame.type === 'error') { - throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details) + throw new RemoteError(frame.error.code as never, frame.error.message, frame.error.details as never) } return } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1d754b05a7..8610dc2881 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -11,10 +11,11 @@ import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import z from '@deepseek-ai/schemastery' +export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { + RemoteError, + remoteErrorOf, remoteMethods, - TypertLookupFailure, - TypertRemoteFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypertCodec, @@ -123,10 +124,12 @@ interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number } -/** Dispatch failure produced outside the invoked business method. */ -export class TypertGatewayError extends Error { - /** Machine-readable failure category. */ - readonly code: TypertGatewayErrorCode +/** + * Dispatch failure produced outside the invoked business method. Rides the + * shared Remote failure vocabulary, so its code crosses the wire instead of + * folding to `internal`. + */ +export class TypertGatewayError extends RemoteError { /** Canonical `/` endpoint. */ readonly endpoint: string /** Affected wire field when the failure is field-specific. */ @@ -145,26 +148,18 @@ export class TypertGatewayError extends Error { message: string, options: GatewayErrorOptions = {}, ) { - super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause }) + super( + code, + `typert gateway: ${endpoint}: ${message}`, + { endpoint, ...options.field === undefined ? {} : { field: options.field } }, + options.cause === undefined ? undefined : { cause: options.cause }, + ) this.name = 'TypertGatewayError' - this.code = code this.endpoint = endpoint this.field = options.field } } -/** Business invocation lost its carrier cancellation race. */ -class RemoteInvocationCancelled extends Error { - /** - * @param endpoint - canonical Remote endpoint. - * @param cause - business rejection observed after carrier cancellation. - */ - constructor(endpoint: string, cause: unknown) { - super(`Remote invocation "${endpoint}" was aborted`, { cause }) - this.name = 'RemoteInvocationCancelled' - } -} - /** * Resolve strict generated definitions or conservative SRC markers against * current Cordis Services and Typert providers. @@ -303,7 +298,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const prepared = await this.prepareInvocation(request) if (prepared.descriptor.mode === 'stream') { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', prepared.endpoint, 'stream Remote methods must be opened through the stream carrier', ) @@ -312,7 +307,7 @@ export class TypertGatewayService extends Service implements TypertGateway { try { return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) throw error } } @@ -326,7 +321,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const prepared = await this.prepareInvocation(request) if (prepared.descriptor.mode !== 'stream') { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', prepared.endpoint, 'unary Remote methods cannot be opened through the stream carrier', ) @@ -335,12 +330,12 @@ export class TypertGatewayService extends Service implements TypertGateway { try { source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) throw error } if (!isIterable(source)) { throw new TypertGatewayError( - 'result-invalid', + 'gateway/result-invalid', prepared.endpoint, 'stream Remote method did not return Iterable or AsyncIterable', { field: 'result' }, @@ -400,7 +395,7 @@ export class TypertGatewayService extends Service implements TypertGateway { || !isPlainObject(payload.args) || Reflect.ownKeys(payload.args).length !== 0) { throw new TypertGatewayError( - 'arguments-invalid', + 'gateway/arguments-invalid', REMOTE_EVENT_STREAM_ENDPOINT, 'forwarded Remote event stream requires an empty args object', ) @@ -408,7 +403,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const registration = this.remoteEvents if (registration === undefined) { throw new TypertGatewayError( - 'service-unavailable', + 'gateway/service-unavailable', REMOTE_EVENT_STREAM_ENDPOINT, 'forwarded Remote event source is unavailable', ) @@ -611,7 +606,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const receiver = receiverContext.get(descriptor.service) as unknown if (!isObject(receiver)) { throw new TypertGatewayError( - 'service-unavailable', + 'gateway/service-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} is unavailable`, ) @@ -624,7 +619,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const method = Reflect.get(receiver, implementation) as unknown if (typeof method !== 'function') { throw new TypertGatewayError( - 'method-unavailable', + 'gateway/method-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) @@ -637,7 +632,7 @@ export class TypertGatewayService extends Service implements TypertGateway { if (strict !== undefined) return strict if (this.ctx.typert.local.hasSeen(endpoint)) { throw new TypertGatewayError( - 'definition-unavailable', + 'gateway/definition-unavailable', endpoint, 'its strict definition was withdrawn and SRC fallback is forbidden', ) @@ -661,11 +656,11 @@ export class TypertGatewayService extends Service implements TypertGateway { candidates.push(this.srcDescriptor(binding, marker, method, endpoint)) } if (candidates.length === 0) { - throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') + throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') } if (candidates.length > 1) { throw new TypertGatewayError( - 'ambiguous-endpoint', + 'gateway/ambiguous-endpoint', endpoint, `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`, ) @@ -683,7 +678,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const signalIndex = names.indexOf('signal') if (signalIndex >= 0 && signalIndex !== names.length - 1) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, 'SRC cancellation parameter signal must be the final parameter', { field: 'signal' }, @@ -700,7 +695,7 @@ export class TypertGatewayService extends Service implements TypertGateway { .filter(definition => definition.parameter === name) if (matches.length > 1) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `parameter ${JSON.stringify(name)} matches multiple lookup providers`, { field: name }, @@ -718,7 +713,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } if (wires.has(parameter.wire)) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, { field: parameter.wire }, @@ -733,14 +728,14 @@ export class TypertGatewayService extends Service implements TypertGateway { const provider = this.ctx.typert.contexts.getHost(marker.invocation.context) if (provider === undefined) { throw new TypertGatewayError( - 'context-unavailable', + 'gateway/context-unavailable', endpoint, `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, ) } if (wires.has(provider.wire)) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, { field: provider.wire }, @@ -778,7 +773,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const provider = this.ctx.typert.contexts.getHost(invocation.context) if (provider === undefined) { throw new TypertGatewayError( - 'context-unavailable', + 'gateway/context-unavailable', endpoint, `Context provider ${JSON.stringify(invocation.context)} is unavailable`, ) @@ -786,7 +781,7 @@ export class TypertGatewayService extends Service implements TypertGateway { if (provider.wire !== invocation.wire || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) { throw new TypertGatewayError( - 'provider-mismatch', + 'gateway/provider-mismatch', endpoint, `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, { field: invocation.wire }, @@ -797,9 +792,9 @@ export class TypertGatewayService extends Service implements TypertGateway { try { context = await provider.resolve(identity) } catch (cause) { - if (cause instanceof TypertLookupFailure) throw cause + if (remoteErrorOf(cause) !== undefined) throw cause throw new TypertGatewayError( - 'context-failed', + 'gateway/context-failed', endpoint, `Context provider ${JSON.stringify(invocation.context)} failed`, { cause, field: invocation.wire }, @@ -807,7 +802,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } if (context === undefined) { throw new TypertGatewayError( - 'context-not-found', + 'gateway/context-not-found', endpoint, `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, { field: invocation.wire }, @@ -832,7 +827,7 @@ export class TypertGatewayService extends Service implements TypertGateway { /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { throw new TypertGatewayError( - 'lookup-unavailable', + 'gateway/lookup-unavailable', endpoint, `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, { field: parameter.wire }, @@ -841,7 +836,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const provider = this.ctx.typert.lookups.get(key) if (provider === undefined) { throw new TypertGatewayError( - 'lookup-unavailable', + 'gateway/lookup-unavailable', endpoint, `lookup provider ${JSON.stringify(key)} is unavailable`, { field: parameter.wire }, @@ -850,7 +845,7 @@ export class TypertGatewayService extends Service implements TypertGateway { if (provider.wire !== parameter.wire || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) { throw new TypertGatewayError( - 'provider-mismatch', + 'gateway/provider-mismatch', endpoint, `lookup provider ${JSON.stringify(key)} does not match its strict definition`, { field: parameter.wire }, @@ -860,9 +855,9 @@ export class TypertGatewayService extends Service implements TypertGateway { try { resolved = await provider.resolve(value) } catch (cause) { - if (cause instanceof TypertLookupFailure) throw cause + if (remoteErrorOf(cause) !== undefined) throw cause throw new TypertGatewayError( - 'lookup-failed', + 'gateway/lookup-failed', endpoint, `lookup provider ${JSON.stringify(key)} failed`, { cause, field: parameter.wire }, @@ -870,7 +865,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } if (resolved === undefined) { throw new TypertGatewayError( - 'lookup-not-found', + 'gateway/lookup-not-found', endpoint, `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, { field: parameter.wire }, @@ -978,11 +973,11 @@ async function *cancellableStream( let rejectAbort: ((error: unknown) => void) | undefined const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { - rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason)) + rejectAbort?.(remoteCancelled(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason) + if (signal.aborted) throw remoteCancelled(endpoint, signal.reason) while (true) { const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) if (next.done === true) return @@ -994,23 +989,20 @@ async function *cancellableStream( } } +/** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ +function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> { + return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause }) +} + function rpcFailure(error: unknown): ConnectionRpcResult { - if (error instanceof RemoteInvocationCancelled) { - return { - ok: false, - error: { code: 'cancelled', message: error.message, details: {} }, - } - } - if (error instanceof TypertLookupFailure) { - return { ok: false, error: error.failure as ConnectionRpcError } - } - if (error instanceof TypertRemoteFailure) { - return { ok: false, error: error.failure } + const remote = remoteErrorOf(error) + if (remote !== undefined) { + return { ok: false, error: { code: remote.code, message: remote.message, details: remote.details } } } return { ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: error instanceof Error ? error.message : String(error), details: {}, }, @@ -1035,7 +1027,7 @@ function validateBinding( const value = Reflect.get(original, 'typertRemote') as unknown if (value === undefined) { throw new TypertGatewayError( - 'binding-invalid', + 'gateway/binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`, ) @@ -1059,7 +1051,7 @@ function readBinding( || typeof Reflect.get(value, 'namespace') !== 'string' || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) { throw new TypertGatewayError( - 'binding-invalid', + 'gateway/binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`, ) @@ -1087,7 +1079,7 @@ function methodParameterNames(service: object, method: string, endpoint: string) } if (implementation === undefined) { throw new TypertGatewayError( - 'method-unavailable', + 'gateway/method-unavailable', endpoint, `Remote marker has no prototype method ${JSON.stringify(method)}`, ) @@ -1110,7 +1102,7 @@ function methodParameterNames(service: object, method: string, endpoint: string) function invalidSignature(endpoint: string, method: string): never { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, ) @@ -1122,7 +1114,7 @@ function assertExactArguments( endpoint: string, ): void { if (!isPlainObject(args)) { - throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object') + throw new TypertGatewayError('gateway/arguments-invalid', endpoint, 'args must be a plain object') } const expected = new Set(descriptor.parameters.map(parameter => parameter.wire)) if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) @@ -1141,7 +1133,7 @@ function assertExactArguments( const clauses: string[] = [] if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`) - throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) + throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) } function decode( @@ -1160,7 +1152,7 @@ function decode( return value } catch (cause) { throw new TypertGatewayError( - 'input-invalid', + 'gateway/input-invalid', endpoint, `wire field ${JSON.stringify(field)} failed boundary validation`, { cause, field }, diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts new file mode 100644 index 0000000000..22f94749f5 --- /dev/null +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -0,0 +1,35 @@ +/** + * Gateway infrastructure failure codes merged into the shared Remote failure + * vocabulary. Face-neutral: the Host face and the Client face each import this + * module so both programs see the same map entries. + */ + +/** Wire details every Gateway infrastructure failure carries. */ +export interface TypertGatewayFaultDetails { + /** Canonical `/` endpoint. */ + readonly endpoint: string + /** Affected wire field when the failure is field-specific. */ + readonly field?: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'gateway/ambiguous-endpoint': TypertGatewayFaultDetails + 'gateway/arguments-invalid': TypertGatewayFaultDetails + 'gateway/binding-invalid': TypertGatewayFaultDetails + 'gateway/context-failed': TypertGatewayFaultDetails + 'gateway/context-not-found': TypertGatewayFaultDetails + 'gateway/context-unavailable': TypertGatewayFaultDetails + 'gateway/definition-unavailable': TypertGatewayFaultDetails + 'gateway/input-invalid': TypertGatewayFaultDetails + 'gateway/invocation-unavailable': TypertGatewayFaultDetails + 'gateway/lookup-failed': TypertGatewayFaultDetails + 'gateway/lookup-not-found': TypertGatewayFaultDetails + 'gateway/lookup-unavailable': TypertGatewayFaultDetails + 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/provider-mismatch': TypertGatewayFaultDetails + 'gateway/result-invalid': TypertGatewayFaultDetails + 'gateway/service-unavailable': TypertGatewayFaultDetails + 'gateway/signature-invalid': TypertGatewayFaultDetails + } +} diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 9b4475cb9a..b456efb4d0 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -99,23 +99,23 @@ export interface TypertGatewayWireStream { /** Stable infrastructure and boundary failures emitted before or after business execution. */ export type TypertGatewayErrorCode = - | 'ambiguous-endpoint' - | 'arguments-invalid' - | 'binding-invalid' - | 'context-failed' - | 'context-not-found' - | 'context-unavailable' - | 'definition-unavailable' - | 'input-invalid' - | 'invocation-unavailable' - | 'lookup-failed' - | 'lookup-not-found' - | 'lookup-unavailable' - | 'method-unavailable' - | 'provider-mismatch' - | 'result-invalid' - | 'service-unavailable' - | 'signature-invalid' + | 'gateway/ambiguous-endpoint' + | 'gateway/arguments-invalid' + | 'gateway/binding-invalid' + | 'gateway/context-failed' + | 'gateway/context-not-found' + | 'gateway/context-unavailable' + | 'gateway/definition-unavailable' + | 'gateway/input-invalid' + | 'gateway/invocation-unavailable' + | 'gateway/lookup-failed' + | 'gateway/lookup-not-found' + | 'gateway/lookup-unavailable' + | 'gateway/method-unavailable' + | 'gateway/provider-mismatch' + | 'gateway/result-invalid' + | 'gateway/service-unavailable' + | 'gateway/signature-invalid' /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index c769192a4b..07a76d685f 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -12,9 +12,16 @@ import { type InvocationDescriptor, type TypertContextMap, type TypertContextWire, - TypertRemoteFailure, + RemoteError, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'fixture/rejected': { readonly retryable: boolean } + 'fixture/broken': { readonly count: bigint } + } +} import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, @@ -118,16 +125,12 @@ class FeedService extends Service { @Remote({ mode: 'stream' }) reject(): Iterable { - throw new TypertRemoteFailure({ - code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false }, - }) + throw new RemoteError('fixture/rejected', 'fixture rejected the stream', { retryable: false }) } @Remote({ mode: 'stream' }) rejectWithNonJsonDetails(): Iterable { - throw new TypertRemoteFailure({ - code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n }, - }) + throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n }) } unary(label: string): string { @@ -262,7 +265,7 @@ describe('Typert Remote streams', () => { }))).resolves.toEqual([1n]) await expect(ctx.typertGateway.stream({ namespace: 'feed', method: 'missing', args: {}, - })).rejects.toMatchObject({ code: 'result-invalid' }) + })).rejects.toMatchObject({ code: 'gateway/result-invalid' }) await expect(collect(await ctx.typertGateway.stream({ namespace: 'feed', method: 'src', args: { label: 'c' }, @@ -286,10 +289,10 @@ describe('Typert Remote streams', () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ namespace: 'feed', method: 'sync', args: { label: 'a' }, - })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) await expect(ctx.typertGateway.stream({ namespace: 'feed', method: 'unary', args: { label: 'a' }, - })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) }) it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => { @@ -345,13 +348,13 @@ describe('Typert Remote streams', () => { { type: 'end', streamId: 'invalid' }, ]) expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({ - type: 'error', error: { code: 'internal' }, + type: 'error', error: { code: 'gateway/internal' }, }) expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({ type: 'error', streamId: 'rejected', error: { - code: 'fixture-rejected', + code: 'fixture/rejected', message: 'fixture rejected the stream', details: { retryable: false }, }, diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 3122fbffd5..519b4dc0cc 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1,3 +1,4 @@ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' @@ -22,7 +23,6 @@ import type { ClientRemote } from '../src/client/index.ts' import { apply, inject, RemoteStream } from '../src/client/index.ts' import { RemoteStreamCarrierError, - RemoteStreamError, RemoteStreamMuxClient, } from '../src/client/stream-client.ts' @@ -634,10 +634,10 @@ describe('Client Typert API', () => { expect(ctx.get('remote.probe')).toBeUndefined() expect(ctx.get('probe')).toBe(businessProbe) expect(ctx.typert.remotes.list()).toEqual([]) - await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({ + await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: Remote method probe/create is no longer mounted', details: {}, }, @@ -1079,10 +1079,10 @@ describe('Client Typert API', () => { await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) - await expect(invocation).resolves.toEqual({ + await expect(invocation).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: Remote method probe/create is no longer mounted', details: {}, }, @@ -1235,14 +1235,14 @@ describe('Client Typert API', () => { }) it('delivers an RPC failure in the error branch with the Host error verbatim', async () => { - const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } + const rpcError = { code: 'gateway/internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' }) expect(outcome.ok).toBe(false) if (outcome.ok) throw new Error('expected the Client API invocation to report a failure') - expect(outcome.error).toBe(rpcError) + expect(outcome.error).toMatchObject(rpcError) }) it('folds a transport throw into the error branch', async () => { @@ -1250,10 +1250,10 @@ describe('Client Typert API', () => { .mockRejectedValue(new Error('carrier offline'))) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: probe/create failed: carrier offline', details: {}, }, @@ -1265,10 +1265,10 @@ describe('Client Typert API', () => { .mockRejectedValue('carrier exploded')) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: probe/create failed: carrier exploded', details: {}, }, @@ -1472,7 +1472,7 @@ describe('Client Typert API', () => { it('fails the Connection generation when a result RPC is rejected', async () => { const call = vi.fn().mockResolvedValue({ ok: false, - error: { code: 'internal', message: 'fixture result rejected', details: {} }, + error: { code: 'gateway/internal', message: 'fixture result rejected', details: {} }, }) const { client, carrier, run } = await eventBench(call) @@ -1554,7 +1554,7 @@ describe('Client Typert API', () => { }) target.remote.$on('fixture/approval', () => Promise.reject(rejection)) - carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled')) + carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'gateway/cancelled')) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) expect(call).toHaveBeenCalledWith( @@ -1884,7 +1884,7 @@ describe('Client Typert API', () => { { name: 'Host failure', stop: (carrier: RemoteEventCarrier) => { - carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {})) + carrier.fail(new RemoteError('gateway/internal', 'fixture Host failed', {})) }, message: 'fixture Host failed', }, @@ -2037,14 +2037,14 @@ describe('Client Typert API', () => { failure: Object.assign(new Error('fixture Host rejected the stream'), { dshRemoteStreamFailure: { kind: 'remote' as const, - code: 'fixture-rejected', + code: 'fixture/rejected', details: { retry: false }, }, }), assert: (error: unknown) => { - expect(error).toBeInstanceOf(RemoteStreamError) + expect(error).toBeInstanceOf(RemoteError) expect(error).toMatchObject({ - code: 'fixture-rejected', + code: 'fixture/rejected', message: 'fixture Host rejected the stream', details: { retry: false }, }) @@ -2122,14 +2122,14 @@ describe('Client Typert API', () => { type: 'error', streamId: failedOpen.streamId, error: { - code: 'lookup-unavailable', + code: 'gateway/lookup-unavailable', message: 'fixture stream failed', details: { lookup: 'missing' }, }, }) await expect(failedItem).rejects.toMatchObject({ - name: 'RemoteStreamError', - code: 'lookup-unavailable', + name: 'RemoteError', + code: 'gateway/lookup-unavailable', message: 'fixture stream failed', details: { lookup: 'missing' }, }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index ab1ee9d6b9..b61a6189f8 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -9,8 +9,8 @@ import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, Remote, + RemoteError, RemoteScope, - TypertLookupFailure, type InvocationDescriptor, type TypertContext, type TypertLookup, @@ -37,6 +37,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertContextMap { gatewayFixture: TypertContext } + + interface RemoteErrorDetailsMap { + 'session/agent-busy': { readonly reason: string } + } } const emptyModel: TypertContribution['model'] = { @@ -452,7 +456,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-unavailable') + }), 'gateway/lookup-unavailable') expect(service.calls).toEqual([]) }) @@ -485,7 +489,7 @@ describe('TypertGatewayService', () => { })).resolves.toBe('land') await expectCode(ctx.typertGateway.invoke({ namespace: 'other', method: 'absent', args: {}, - }), 'invocation-unavailable') + }), 'gateway/invocation-unavailable') }) it('rejects SRC wire collisions and unavailable Context providers', async () => { @@ -496,14 +500,14 @@ describe('TypertGatewayService', () => { namespace: 'colliding-wire', method: 'run', args: { agentId: 'agent-1' }, - }), 'signature-invalid') + }), 'gateway/signature-invalid') const missing = await setup() await expectCode(missing.ctx.typertGateway.invoke({ namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-unavailable') + }), 'gateway/context-unavailable') const contextCollision = await setupGateway() await contextCollision.plugin(ContextWireService) @@ -512,7 +516,7 @@ describe('TypertGatewayService', () => { namespace: 'context-wire', method: 'run', args: { agentId: 'agent-1' }, - }), 'signature-invalid') + }), 'gateway/signature-invalid') }) it('re-reads Service and providers on every strict invocation', async () => { @@ -526,7 +530,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-unavailable') + }), 'gateway/lookup-unavailable') registerAgentLookup(ctx, agent) await serviceFiber.dispose() @@ -534,7 +538,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'service-unavailable') + }), 'gateway/service-unavailable') }) it('re-reads and contains Context providers', async () => { @@ -548,7 +552,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-unavailable') + }), 'gateway/context-unavailable') ctx.typert.contexts.registerHost('gatewayFixture', { ...contextProvider(scoped), @@ -558,13 +562,13 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-failed') + }), 'gateway/context-failed') expect(error.cause).toEqual(new Error('provider failed')) }) it('preserves a Host Context policy rejection for the active RPC adapter', async () => { const { ctx } = await setup() - const rejection = new TypertLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } }) + const rejection = new RemoteError('session/agent-busy', 'owned', { reason: 'subagent' }) ctx.typert.contexts.registerHost('gatewayFixture', { ...contextProvider(ctx.extend()), resolve: async () => { throw rejection }, @@ -590,7 +594,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'provider-mismatch') + }), 'gateway/provider-mismatch') await mismatch() ctx.typert.contexts.registerHost('gatewayFixture', { @@ -601,7 +605,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-not-found') + }), 'gateway/context-not-found') }) it('contains lookup provider failures and missing identities', async () => { @@ -615,7 +619,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-failed') + }), 'gateway/lookup-failed') expect(failure.cause).toEqual(new Error('lookup failed')) await throwing() @@ -627,7 +631,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-not-found') + }), 'gateway/lookup-not-found') await missing() ctx.typert.lookups.register('gatewayFixture', { @@ -650,7 +654,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, - }), 'definition-unavailable') + }), 'gateway/definition-unavailable') }) it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { @@ -665,7 +669,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, - }), 'definition-unavailable') + }), 'gateway/definition-unavailable') }) it('retains the no-downgrade guard across Gateway Service reloads', async () => { @@ -684,7 +688,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, - }), 'definition-unavailable') + }), 'gateway/definition-unavailable') }) it('rejects ambiguous SRC endpoints independently of reflection order', async () => { @@ -696,7 +700,7 @@ describe('TypertGatewayService', () => { namespace: 'shared', method: 'run', args: { value: 'ship' }, - }), 'ambiguous-endpoint') + }), 'gateway/ambiguous-endpoint') expect(error.message).toContain('firstShared, secondShared') }) @@ -714,7 +718,7 @@ describe('TypertGatewayService', () => { namespace: testCase.namespace, method: 'run', args: testCase.args, - }), 'signature-invalid') + }), 'gateway/signature-invalid') } }) @@ -728,7 +732,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'signature-invalid') + }), 'gateway/signature-invalid') }) it('requires exact wire fields before invoking business code', async () => { @@ -739,17 +743,17 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { request: { title: 'ship' } }, - }), 'arguments-invalid') + }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, - }), 'arguments-invalid') + }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: [] as unknown as Record, - }), 'arguments-invalid') + }), 'gateway/arguments-invalid') expect(service.calls).toEqual([]) }) @@ -761,7 +765,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'strictOnly', args: { request: { title: 1 } }, - }), 'input-invalid') + }), 'gateway/input-invalid') service.nextResult = { title: 1 } await expect(ctx.typertGateway.invoke({ @@ -799,7 +803,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value }, - }), 'input-invalid') + }), 'gateway/input-invalid') }) it('admits an omitted SRC field and hands the Host method undefined', async () => { @@ -823,7 +827,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: cyclic }, - }), 'input-invalid') + }), 'gateway/input-invalid') const result = new Date(0) service.nextResult = result @@ -855,7 +859,7 @@ describe('TypertGatewayService', () => { for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'passthrough', args: { value }, - }), 'input-invalid') + }), 'gateway/input-invalid') } }) @@ -871,7 +875,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'provider-mismatch') + }), 'gateway/provider-mismatch') }) it('validates binding identity and active method availability', async () => { @@ -881,7 +885,7 @@ describe('TypertGatewayService', () => { namespace: 'wrong-binding', method: 'run', args: { value: 'ship' }, - }), 'binding-invalid') + }), 'gateway/binding-invalid') await ctx.plugin(GoalService) registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) @@ -889,7 +893,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'missing', args: { value: 'ship' }, - }), 'method-unavailable') + }), 'gateway/method-unavailable') }) it('requires a visible binding and supports explicitly provided plain Services', async () => { @@ -904,7 +908,7 @@ describe('TypertGatewayService', () => { }]) await expectCode(ctx.typertGateway.invoke({ namespace: 'no-binding', method: 'run', args: { value: 'ship' }, - }), 'binding-invalid') + }), 'gateway/binding-invalid') const plain: { typertRemote?: ReturnType @@ -941,7 +945,7 @@ describe('TypertGatewayService', () => { try { await expectCode(ctx.typertGateway.invoke({ namespace: 'missing-method', method: 'run', args: { value: 'ship' }, - }), 'method-unavailable') + }), 'gateway/method-unavailable') } finally { Object.defineProperty(MissingMethodService.prototype, 'run', descriptor) } @@ -965,7 +969,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'absent', args: {}, - }), 'invocation-unavailable') + }), 'gateway/invocation-unavailable') }) it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { @@ -1002,7 +1006,7 @@ describe('TypertGatewayService', () => { const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) @@ -1018,13 +1022,13 @@ describe('TypertGatewayService', () => { for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { const result = await handler(endpoint, { args: {} }, signal) - expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded') expect(result.error.message).toContain('invalid Remote endpoint') } for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) { const result = await handler('goals/create', payload, signal) - expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(result.error.message).toContain('plain-object args field') } @@ -1036,7 +1040,7 @@ describe('TypertGatewayService', () => { new AbortController().signal, )).resolves.toEqual({ ok: false, - error: { code: 'internal', message: 'non-error failure', details: {} }, + error: { code: 'gateway/internal', message: 'non-error failure', details: {} }, }) // A business rejection observed while the carrier signal is already aborted @@ -1051,7 +1055,7 @@ describe('TypertGatewayService', () => { )).resolves.toEqual({ ok: false, error: { - code: 'cancelled', + code: 'gateway/cancelled', message: 'Remote invocation "goals/fail" was aborted', details: {}, }, @@ -1075,7 +1079,7 @@ describe('TypertGatewayService', () => { args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } }, } const inactive = await handler('$events/result', result, new AbortController().signal) - expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(inactive).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded') expect(inactive.error.message).toContain('identifies no active event stream') @@ -1098,7 +1102,7 @@ describe('TypertGatewayService', () => { for (const payload of [null, [], {}, { other: {} }]) { const invalid = await handler('$events/result', payload, carrier.signal) - expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(invalid).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded') expect(invalid.error.message).toContain('requires exactly one plain-object args field') } @@ -1122,13 +1126,13 @@ describe('TypertGatewayService', () => { await ctx.plugin(GoalService) registerStrict(ctx, [createDescriptor()]) const failure = { - code: 'agent-busy', + code: 'session/agent-busy', message: 'session is owned by subagent routing', details: { reason: 'use subagent delivery for this child session' }, } ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => { throw new TypertLookupFailure(failure) }, + resolve: () => { throw new RemoteError('session/agent-busy', failure.message, failure.details) }, }) const handler = rawConnection(ctx).handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') @@ -1225,7 +1229,7 @@ describe('TypertGatewayService', () => { rpcId: 'rpc-invalid', result: { ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }, }) expect(JSON.stringify(invalidBody)).toContain('plain-object args field') @@ -1249,7 +1253,7 @@ describe('TypertGatewayService', () => { rpcId: 'rpc-withdrawn', result: { ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/definition-unavailable' }, }, }) expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') diff --git a/packages/api/gateway/tsconfig.client.json b/packages/api/gateway/tsconfig.client.json index 31df1266af..1fa6ae97c0 100644 --- a/packages/api/gateway/tsconfig.client.json +++ b/packages/api/gateway/tsconfig.client.json @@ -12,6 +12,7 @@ "src/client/remote-stream.ts", "src/client/snapshot-stream.ts", "src/client/stream-client.ts", + "src/remote-error-codes.ts", "src/stream-protocol.ts" ], "references": [ diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 54d5f964f3..8a3fc71910 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -8,6 +8,7 @@ "files": [ "src/index.ts", "src/invariant.ts", + "src/remote-error-codes.ts", "src/stream-protocol.ts", "src/stream-server.ts", "src/types.ts" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index d449efe704..43821e10b9 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -56,7 +56,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' export type { ConnectionHandle, ConnectionSinks, ContentBlock, MessageId, - RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, + RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' @@ -112,7 +112,7 @@ export type { } from '@deepseek-ai/dsh-settings/types' // Provider registry and discovery vocabulary for the llm namespace. export type { - LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryError, + LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryRequest, LlmProviderInfo, } from '@deepseek-ai/dsh-llm/types' // Reference-discovery result vocabulary for the fileReferences and @@ -120,21 +120,14 @@ export type { export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' -/** Failure vocabulary exposed by the assembled Client data layer. */ -export type ClientFailure = - | import('@deepseek-ai/dsh-client-connection/client').RpcError - | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError - | import('@deepseek-ai/dsh-api-session-controller/types').SessionError - | import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError - | import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError - | import('@deepseek-ai/dsh-llm/types').LlmModelDiscoveryError - | import('@deepseek-ai/dsh-subagent/client').SubagentControlError - | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError - -/** Success or failure returned by Client operations spanning both API families. */ -export type ClientResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: ClientFailure } +// The Remote failure vocabulary, re-exported so business packages keep naming +// this assembly alone. Types only: a value export would make spec imports load +// this module's owner /remote artifacts; specs take RemoteError from +// dsh-client-test-runtime instead. +export type { + RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure, RemoteResult, +} from '@deepseek-ai/dsh-typert-protocol' +export type { RemoteHostFacts } from '@deepseek-ai/dsh-api-gateway/client' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 0cdd53a2a3..3cab420545 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -99,6 +99,7 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", @@ -137,6 +138,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index f96c66b464..c17929af5c 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -11,9 +11,9 @@ import type {} from '@deepseek-ai/dsh-agent-presets' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { ModelSelection, SessionError } from './types.ts' +import type { ModelSelection } from './types.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -57,10 +57,7 @@ export class ApiSessionPresetConflict extends Error { } /** Failures produced while resolving one ordinary Session identity to its live Agent. */ -export type ApiSessionAgentError = Extract< - SessionError, - { readonly code: 'session-not-found' | 'agent-busy' | 'internal' } -> +export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'gateway/internal'> /** Result of resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentResult = @@ -97,11 +94,11 @@ export function hasApiSessionSubagentOwner( * @returns a stable Session-domain failure. */ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } + return new RemoteError( + 'session/agent-busy', + `session "${sessionId}" is owned by subagent routing`, + { reason: 'use subagent delivery for this child session' }, + ) } /** @@ -145,17 +142,17 @@ export class ApiSessionAgentController { constructor(private readonly ctx: Context) { ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) + if ('error' in found) throw found.error return found.agent }) ctx.typert.lookups.configure('session', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) + if ('error' in found) throw found.error return found.agent.session }) ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) + if ('error' in found) throw found.error return found.agent.ctx }) } @@ -198,13 +195,7 @@ export class ApiSessionAgentController { return { agent: await resume } } catch (error: unknown) { if (error instanceof ApiSessionNotFound) { - return { - error: { - code: 'session-not-found', - message: error.message, - details: { sessionId }, - }, - } + return { error: new RemoteError('session/not-found', error.message, { sessionId }) } } if (error instanceof ApiSessionSubagentOwnership) { return { error: apiSessionSubagentOwnershipError(error.sessionId) } @@ -216,11 +207,11 @@ export class ApiSessionAgentController { return { error: apiSessionSubagentOwnershipError(sessionId) } } return { - error: { - code: 'internal', - message: `resume failed for session "${sessionId}": ${String(error)}`, - details: {}, - }, + error: new RemoteError( + 'gateway/internal', + `resume failed for session "${sessionId}": ${String(error)}`, + {}, + ), } } } diff --git a/packages/api/session-controller/src/client/contract/result.ts b/packages/api/session-controller/src/client/contract/result.ts deleted file mode 100644 index 5306e611de..0000000000 --- a/packages/api/session-controller/src/client/contract/result.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Client operation results spanning the Session and subagent Remote calls. */ - -import type { RpcError } from '@deepseek-ai/dsh-client-connection/client' -import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client' -import type { SessionError } from '../../types.ts' - -/** Failure surfaced by the Client Session object layer. */ -export type ClientFailure = RpcError | SessionError | SubagentControlError - -/** Success or failure returned by a Client Session operation. */ -export type ClientResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: ClientFailure } - -/** - * Fold a rejected carrier operation into the Client Session failure vocabulary. - * @param error - rejection from a Remote or local carrier call. - * @returns the failure branch of a Client Session result. - */ -export function transportResult(error: unknown): ClientResult { - return { - ok: false, - error: { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, - } -} diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 9b8ed3f7ec..7810249c46 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -13,7 +13,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' -import type { ClientResult } from './result.ts' import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' /** @@ -84,7 +83,7 @@ export interface ISession { mode: 'queue' | 'steer', signal?: AbortSignal, requestId?: SessionRequestId, - ): Promise> + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. @@ -92,27 +91,27 @@ export interface ISession { */ readAttachment( attachmentId: AttachmentIdType, - ): Promise> + ): Promise> /** * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. * @param action - requested queue operation. * @returns acceptance, or a business/transport error. */ - updateQueue(itemId: MessageId, action: QueueAction): Promise> + updateQueue(itemId: MessageId, action: QueueAction): Promise> /** * Cancel the running turn. Pending queued work remains and resumes in FIFO * order after the Host reaches cancellation quiescence. * @returns acceptance, or the business error. */ - cancel(): Promise> + cancel(): Promise> /** * Rename this session (explicit user title; pins it against automatic * regeneration). * @param title - raw title text (the host normalizes acceptance). * @returns the normalized accepted title and its event seq, or the business error. */ - rename(title: string): Promise> + rename(title: string): Promise> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 7c8a90e662..08c13a208e 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -8,10 +8,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { AgentContext } from '../scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState } from '../sessions/service.ts' -import type { ClientResult } from './result.ts' import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -83,7 +83,7 @@ export interface ISessions { search( query: string, signal: AbortSignal, - ): Promise> + ): Promise> /** * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 8aacc0d5d6..b0e12a27b8 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -3,8 +3,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRequestId } from '../../types.ts' -import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ export interface QueuedMessage { @@ -53,7 +53,7 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' /** Send/stop failure surfaced by Session consumers. */ export interface PromptError { readonly op: 'send' | 'stop' - readonly error: ClientFailure + readonly error: RemoteFailure } /** Immutable Session lifecycle and control snapshot. */ @@ -70,7 +70,7 @@ export interface SessionSnapshot { } | null readonly removed: boolean readonly openState: OpenState - readonly openError: ClientFailure | null + readonly openError: RemoteFailure | null readonly hasMore: boolean readonly loadingOlder: boolean readonly promptError: PromptError | null diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 04b7cbb553..1095ad26fc 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,7 +2,6 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -13,7 +12,6 @@ export { SessionEventStream, SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - sessionStreamFailure, } from './transport.ts' export type { ClientSessionPageRequest, @@ -66,7 +64,6 @@ export type { QueuedMessage, SessionSnapshot, } from './contract/snapshot.ts' -export type { ClientFailure, ClientResult } from './contract/result.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -75,9 +72,8 @@ declare module '@deepseek-ai/cordis' { } } -/** Required wire, Remote, and Context projection services. */ +/** Required Remote and Context projection services. */ export const inject = [ - 'connection', 'typert', 'remote', 'remote.commands', @@ -90,7 +86,6 @@ export const inject = [ * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { - const connection = ctx.get('connection') as ConnectionHandle const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) @@ -111,7 +106,7 @@ export function apply(ctx: Context): void { }) control.start() ctx.on('connection/reset', () => { sessions.handleConnected() }) - if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected() + if (ctx.remote.$host.home !== undefined) sessions.handleConnected() ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), resolve: sessionId => sessions.resolveAgentScope(sessionId), diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 9ad6d88f96..47392c86f3 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -9,13 +9,12 @@ import type { SessionControlBaseline, SessionControlFrame, SessionQueuedItem, - SessionError, SessionSummary, SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' -import type { ClientFailure, ClientResult } from '../contract/result.ts' -import { transportResult } from '../contract/result.ts' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' // Type-only merge edge: the title domain's client-namespace outlet declares @@ -51,7 +50,7 @@ export interface SessionListSnapshot { state: 'idle' | 'loading' | 'error' /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ phase: SessionListPhase - error: ClientFailure | null + error: RemoteFailure | null subagentsByParent: Readonly> /** Background jobs per session; an absent key is an empty set. */ jobsBySession: Readonly> @@ -63,7 +62,7 @@ export type SubagentCatalogSnapshot = Omit & /** Absent until the first successful catalog read. */ readonly parentAvailable?: boolean state: 'loading' | 'ready' | 'error' - error: ClientFailure | null + error: RemoteFailure | null } function catalogAvailability(parentAvailable: boolean | undefined): { @@ -112,7 +111,7 @@ export class SessionManager { private listState: 'idle' | 'loading' | 'error' = 'idle' /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ private listPhase: SessionListPhase = 'pending' - private listError: ClientFailure | null = null + private listError: RemoteFailure | null = null private listInflight: Promise | null = null /** Mutations arriving after a list request starts are replayed over its response. */ private listMutations: SessionListMutation[] | null = null @@ -366,7 +365,7 @@ export class SessionManager { this.notifier.markDirty() const operation = (async () => { try { - const result = toSessionResult(await this.remote.subagents.list(parentSessionId)) + const result = await this.remote.subagents.list(parentSessionId) if (result.ok) { const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride ?? result.value.parentAvailable @@ -395,7 +394,7 @@ export class SessionManager { }) } } catch (error: unknown) { - const folded = transportResult(error) + if (!isRemoteFailure(error)) throw error this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, @@ -405,7 +404,7 @@ export class SessionManager { ?? previous?.parentAvailable, ), state: 'error', - error: folded.ok ? null : folded.error, + error, }) } finally { this.catalogInflight.delete(parentSessionId) @@ -457,7 +456,7 @@ export class SessionManager { this.notifier.markDirty() this.listInflight = (async () => { try { - const result = toSessionResult(await this.remote.session.list({})) + const result = await this.remote.session.list({}) if (result.ok) { const baseline: SessionSummary[] = this.listPhase === 'pending' ? [...result.value.items] @@ -506,10 +505,9 @@ export class SessionManager { this.listError = result.error } } catch (error) { + if (!isRemoteFailure(error)) throw error this.listState = 'error' - const folded = transportResult(error) - /* v8 ignore next -- the `? null` arm is unreachable: transportResult always returns ok:false. */ - this.listError = folded.ok ? null : folded.error + this.listError = error } finally { this.listMutations = null this.listInflight = null @@ -529,19 +527,15 @@ export class SessionManager { async search( query: string, signal: AbortSignal, - ): Promise> { - try { - const result = toSessionResult(await this.remote.session.search({ query }, signal)) - if (!result.ok) return result - return { - ok: true, - value: { - items: [...result.value.items], - hasMore: result.value.hasMore, - }, - } - } catch (error: unknown) { - return transportResult(error) + ): Promise> { + const result = await this.remote.session.search({ query }, signal) + if (!result.ok) return result + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, } } @@ -558,36 +552,32 @@ export class SessionManager { cwd?: string sessionId?: SessionId } = {}, - ): Promise> { - try { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } - const payload = opts.workspaceId !== undefined - ? { workspaceId: opts.workspaceId, ...shared } - : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } - const result = toSessionResult(await this.remote.session.create(payload)) - if (result.ok) { + ): Promise> { + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const payload = opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...shared } + : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } + const result = await this.remote.session.create(payload) + if (result.ok) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + } }) + } else { + const publishedSessionId = workspaceAttachSessionId(result.error) + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { this.recordMutation({ kind: 'upsert', summary: { - sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, - ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + blank: true, } }) - } else { - const publishedSessionId = workspaceAttachSessionId(result.error) - // Publication precedes attachment. The error's id is a real Session, - // so expose it immediately as Ungrouped while the caller keeps the - // prompt buffer and decides whether to retry attachment. - if (publishedSessionId !== undefined) { - this.recordMutation({ kind: 'upsert', summary: { - sessionId: publishedSessionId, - updatedAt: Date.now(), - running: false, - blank: true, - } }) - } } - return result - } catch (error) { - return transportResult(error) } + return result } /** @@ -601,27 +591,23 @@ export class SessionManager { */ async fork( opts: { sessionId: SessionId; atSeq?: number }, - ): Promise> { - try { - const source = this.summaries.find(s => s.sessionId === opts.sessionId) - const result = toSessionResult(await this.remote.session.fork({ - sessionId: opts.sessionId, - ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, - })) - const childId = result.ok - ? result.value.sessionId - : workspaceAttachSessionId(result.error) - if (childId !== undefined) { - this.recordMutation({ kind: 'upsert', summary: { - sessionId: childId, updatedAt: Date.now(), running: false, blank: false, - parentSessionId: opts.sessionId, - ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), - } }) - } - return result - } catch (error) { - return transportResult(error) + ): Promise> { + const source = this.summaries.find(s => s.sessionId === opts.sessionId) + const result = await this.remote.session.fork({ + sessionId: opts.sessionId, + ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, + }) + const childId = result.ok + ? result.value.sessionId + : workspaceAttachSessionId(result.error) + if (childId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + parentSessionId: opts.sessionId, + ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), + } }) } + return result } /** @@ -1010,13 +996,6 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi } /** Temporary source-plane bridge while the Host contract and client project build independently. */ -function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined { - return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined -} - -/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ -function toSessionResult( - result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult, -): ClientResult { - return result.ok ? result : { ok: false, error: result.error as SessionError } +function workspaceAttachSessionId(error: RemoteFailure): SessionId | undefined { + return error.code === 'session/workspace-attach-failed' ? error.details.sessionId : undefined } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index a54d1ece0d..3e6c8bf6e0 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -25,7 +25,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' -import type { ClientFailure, ClientResult } from '../contract/result.ts' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' @@ -101,7 +101,7 @@ export class SessionCreateError extends Error { * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. */ constructor( - readonly rpcError: ClientFailure, + readonly rpcError: RemoteFailure, readonly requestedSessionId: SessionId | undefined, ) { super(`session create failed: ${rpcError.code}: ${rpcError.message}`) @@ -117,7 +117,7 @@ export class SessionForkError extends Error { * @param sourceSessionId - the session the fork was cut from. */ constructor( - readonly rpcError: ClientFailure, + readonly rpcError: RemoteFailure, readonly sourceSessionId: SessionId, ) { super(`session fork failed: ${rpcError.code}: ${rpcError.message}`) @@ -335,7 +335,7 @@ export class ClientSessions implements ISessions { search( query: string, signal: AbortSignal, - ): Promise> { + ): Promise> { return this.manager.search(query, signal) } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 815cf89c40..8af47b5aad 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -6,10 +6,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { - SessionEventStream, - sessionStreamFailure, -} from '../transport.ts' +import { SessionEventStream } from '../transport.ts' import type { SessionJournalChange } from '../transport.ts' import type { PromptContentPart, @@ -18,10 +15,7 @@ import type { SessionControlFrame, SessionQueuedItem, SessionRequestId, - SessionError, } from '../../types.ts' -import type { ClientFailure, ClientResult } from '../contract/result.ts' -import { transportResult } from '../contract/result.ts' import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' @@ -33,7 +27,8 @@ import type { SessionEventLikeEntry, SessionLiveEventEntry, } from '../contract/events.ts' import { Notifier } from './notifier.ts' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' @@ -77,7 +72,7 @@ export class Session implements SessionFace { private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' - private openError: ClientFailure | null = null + private openError: RemoteFailure | null = null private openPromise: Promise | null = null /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale * passes drop all writes once the generation moves on. */ @@ -214,7 +209,7 @@ export class Session implements SessionFace { mode: 'queue' | 'steer', signal?: AbortSignal, requestId?: SessionRequestId, - ): Promise> { + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -223,52 +218,26 @@ export class Session implements SessionFace { this.promptAttempted = true if (this.blankBit) this.firstPromptPendingTurn = true this.notifier.markDirty() - let result: ClientResult<{ accepted: true }> - try { - if (this.address === undefined) { - const clientTimeZone = resolvedClientTimeZone() - result = toSessionResult(await this.remote.session.prompt({ - requestId: requestId ?? randomUUID() as SessionRequestId, - sessionId: this.sessionId, - mode, - content, - clientTimeZone, - }, signal)) - } else if (this.address.mode === 'one-shot') { - result = { - ok: false, - error: { - code: 'subagent-not-resumable', - message: 'one-shot subagent conversations are read-only', - details: { childSessionId: this.address.childSessionId }, - }, - } - } else { - if (content.some(part => part.type === 'image')) { - result = { - ok: false, - error: { - code: 'attachment-error', - message: 'Image input is unavailable for subagent continuations.', - details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, - }, - } - } else { - const routed = toSessionResult(await this.remote.subagents.prompt({ - requestId: randomUUID() as SessionRequestId, - parentSessionId: this.address.parentSessionId, - childSessionId: this.address.childSessionId, - mode: this.address.mode, - content: content.flatMap(part => part.type === 'text' - ? [{ type: 'text' as const, text: part.text }] - : []), - clientTimeZone: resolvedClientTimeZone(), - }, signal)) - result = routed.ok ? { ok: true, value: { accepted: true } } : routed - } - } - } catch (error) { - result = transportResult(error) + let result: RemoteResult<{ accepted: true }> + if (this.address === undefined) { + const clientTimeZone = resolvedClientTimeZone() + result = await this.remote.session.prompt({ + requestId: requestId ?? randomUUID() as SessionRequestId, + sessionId: this.sessionId, + mode, + content, + clientTimeZone, + }, signal) + } else { + const routed = await this.remote.subagents.prompt({ + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: 'continuable', + content, + clientTimeZone: resolvedClientTimeZone(), + }, signal) + result = routed.ok ? { ok: true, value: { accepted: true } } : routed } if (!result.ok) { if (requestId !== undefined) this.retireFailedSubmission(requestId) @@ -299,66 +268,38 @@ export class Session implements SessionFace { */ async readAttachment( attachmentId: AttachmentIdType, - ): Promise> { - try { - const result = await this.remote.session.attachment({ - sessionId: this.sessionId, - attachmentId, - }) - if (!result.ok) return toSessionResult(result) - const binary = atob(result.value.data) - const data = Uint8Array.from(binary, char => char.charCodeAt(0)) - return { ok: true, value: { attachment: result.value.attachment, data } } - } catch (error) { - return transportResult(error) - } + ): Promise> { + const result = await this.remote.session.attachment({ + sessionId: this.sessionId, + attachmentId, + }) + if (!result.ok) return result + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } } /** Apply one operation to a still-pending queue occurrence. */ - async updateQueue(itemId: MessageId, action: QueueAction): Promise> { - try { - return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })) - } catch (error) { - return transportResult(error) - } + async updateQueue(itemId: MessageId, action: QueueAction): Promise> { + return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }) } /** * Stop the active turn while the Host preserves pending inbox work; failures - * land in promptError (same error-strip display slot). A continuable - * subagent address routes through `subagents.interruptByParent`, whose durable - * parent-address authority works without a live parent Agent; a one-shot - * address stays uncancellable (the UI offers no stop action, so this arm is - * defensive). + * land in promptError (same error-strip display slot). A subagent address + * routes through `subagents.interruptByParent`, whose durable parent-address + * authority works without a live parent Agent. * @returns the cancel result. */ - async cancel(): Promise> { + async cancel(): Promise> { const address = this.address - if (address !== undefined && address.mode === 'one-shot') { - const result: ClientResult<{ accepted: true }> = { - ok: false, - error: { - code: 'subagent-delivery-unavailable', - message: 'subagent activation cancellation is unavailable', - details: { childSessionId: address.childSessionId }, - }, - } - this.promptError = { op: 'stop', error: result.error } - this.notifier.markDirty() - return result - } - let result: ClientResult<{ accepted: true }> - try { - result = address !== undefined - ? toSessionResult(await this.remote.subagents.interruptByParent( - address.childSessionId, - address.parentSessionId, - address.mode, - )) - : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId })) - } catch (error) { - result = transportResult(error) - } + const result = address !== undefined + ? await this.remote.subagents.interruptByParent( + address.childSessionId, + address.parentSessionId, + 'continuable', + ) + : await this.remote.session.cancel({ sessionId: this.sessionId }) if (!result.ok) { this.promptError = { op: 'stop', error: result.error } this.notifier.markDirty() @@ -375,14 +316,10 @@ export class Session implements SessionFace { * @param title - raw title text (the host normalizes acceptance). * @returns the rename result (normalized accepted title + title event seq). */ - async rename(title: string): Promise> { - try { - const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title })) - if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) - return result - } catch (error) { - return transportResult(error) - } + async rename(title: string): Promise> { + const result = await this.remote.session.rename({ sessionId: this.sessionId, title }) + if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) + return result } /** @@ -390,7 +327,7 @@ export class Session implements SessionFace { * admission semantics (the host executor durably logs the lifecycle; * outcomes render as flow nodes, never as a response echo). * @param line - the full command line, leading slash included. - * @returns the admission result, or the error branch on transport failure. + * @returns the admission result. */ async command(line: string): Promise> { const result = await this.remote.commands.execute(this.sessionId, line, []) @@ -420,7 +357,7 @@ export class Session implements SessionFace { try { await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) } catch (error) { - if (sessionStreamFailure(error) === undefined) { + if (!isRemoteFailure(error)) { console.error('[session-controller] loadOlder failed:', error) } } finally { @@ -600,9 +537,10 @@ export class Session implements SessionFace { this.openState = 'open' } catch (error) { if (generation !== this.openGeneration || this.events !== events) return + if (!isRemoteFailure(error)) throw error this.events = undefined this.openState = 'error' - this.openError = openFailure(error) + this.openError = error } finally { if (generation === this.openGeneration) this.notifier.markDirty() } @@ -714,11 +652,12 @@ export class Session implements SessionFace { /** Publish a terminal background failure only while this stream still owns the Session. */ private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { if (generation !== this.openGeneration || this.events !== events) return + if (!isRemoteFailure(error)) throw error this.openGeneration++ this.events = undefined this.openPromise = null this.openState = 'error' - this.openError = openFailure(error) + this.openError = error void events.dispose() this.notifier.markDirty() } @@ -774,17 +713,3 @@ function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { } return refs } - -/** Convert a terminal Session stream failure to the Client error vocabulary. */ -function openFailure(error: unknown): ClientFailure { - const failure = sessionStreamFailure(error) - if (failure !== undefined) return failure as SessionError - const folded = transportResult(error) - /* v8 ignore next -- transportResult never returns an ok result. */ - if (folded.ok) throw new Error('transportResult returned an unexpected success') - return folded.error -} -/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ -function toSessionResult(result: RemoteResult): ClientResult { - return result.ok ? result : { ok: false, error: result.error as SessionError } -} diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 48298268bb..69a9b934e1 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -1,12 +1,10 @@ /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote' -import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { RemoteJournalStream, RemoteSnapshotStream, RemoteStreamCarrierError, - RemoteStreamError, type ClientRemote, type RemoteJournalChange, type RemoteJournalFrame, @@ -25,6 +23,7 @@ import { historyRecordLastSeq, } from './sessions/history-records.ts' import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts' +import type { SessionRemotes } from './sessions/remotes.ts' export { SESSION_SEARCH_RESULT_LIMIT, @@ -80,8 +79,6 @@ export type SessionControlStream = RemoteSnapshotStream< SessionControlDeltaFrame > -type SessionStreamRemote = Pick - /** Domain sinks used by the Host-wide Session control stream. */ export interface SessionControlStreamOptions { /** Apply a complete baseline or one later update. */ @@ -109,7 +106,7 @@ export interface SessionEventStreamOptions { * @returns an unstarted stream owned by the Client Session runtime. */ export function createSessionControlStream( - remote: SessionStreamRemote, + remote: SessionRemotes, options: SessionControlStreamOptions, ): SessionControlStream { const stream = remote.$stream({ @@ -142,7 +139,7 @@ export class SessionEventStream extends RemoteJournalStream< * @param options - Session event-window destinations. */ constructor( - private readonly remote: SessionStreamRemote, + private readonly remote: SessionRemotes, private readonly address: SessionAddress, options: SessionEventStreamOptions, ) { @@ -198,13 +195,7 @@ export class SessionEventStream extends RemoteJournalStream< { address: this.address, throughSeq, ...request }, signal, ) - if (!result.ok) { - throw new RemoteStreamError( - result.error.code, - result.error.message, - result.error.details, - ) - } + if (!result.ok) throw result.error return result.value } @@ -215,13 +206,3 @@ export class SessionEventStream extends RemoteJournalStream< return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages } } } - -/** - * Recover a Host Session failure from a Remote stream terminal error. - * @param error - value thrown while opening or consuming a Session stream. - * @returns the Host failure, or `undefined` for carrier and local failures. - */ -export function sessionStreamFailure(error: unknown): RemoteFailure | undefined { - if (!(error instanceof RemoteStreamError)) return undefined - return { code: error.code, message: error.message, details: error.details } -} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..3abefd52fa 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { @@ -14,7 +13,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' import { ApiSessionAgentController, @@ -71,14 +71,14 @@ export class SessionCommandController { */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { - reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {}) + throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) if (workspace === undefined) { - reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, { + throw new RemoteError('workspace/not-found', `workspace "${request.workspaceId}" not found`, { workspaceId: request.workspaceId, }) } @@ -99,8 +99,8 @@ export class SessionCommandController { try { await workspace.attachSession(sessionId) } catch (error) { - reject( - 'workspace-attach-failed', + throw new RemoteError( + 'session/workspace-attach-failed', `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, { sessionId, workspaceId: workspace.id }, ) @@ -143,9 +143,9 @@ export class SessionCommandController { } return { selected: { ...selected } } } catch (error) { - if (error instanceof TypertRemoteFailure) throw error - reject( - 'model-unavailable', + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError( + 'session/model-unavailable', error instanceof Error ? error.message : String(error), { provider: request.provider, model: request.model }, ) @@ -162,17 +162,17 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) const titles = this.ctx.get('sessionTitle') if (titles === undefined) { - reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) + throw new RemoteError('gateway/internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) } try { const accepted = titles.rename(agent.session, request.title) return { title: accepted.title, seq: accepted.eventSeq } } catch (error) { if (error instanceof SessionTitleInvalidError) { - reject('title-invalid', error.message, { sessionId: request.sessionId }) + throw new RemoteError('session/title-invalid', error.message, { sessionId: request.sessionId }) } - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `failed to rename session "${request.sessionId}": ${String(error)}`, {}, ) @@ -187,7 +187,7 @@ export class SessionCommandController { async fork(request: SessionForkRequest): Promise { if (request.atSeq !== undefined && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) { - reject('bad-request', 'atSeq must be a non-negative integer', {}) + throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative integer', {}) } let observed: SessionObservation try { @@ -195,12 +195,12 @@ export class SessionCommandController { } catch (error) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - reject('session-not-found', `session "${request.sessionId}" not found`, { + throw new RemoteError('session/not-found', `session "${request.sessionId}" not found`, { sessionId: request.sessionId, }) } - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `fork source unavailable for session "${request.sessionId}": ${String(error)}`, {}, ) @@ -216,8 +216,8 @@ export class SessionCommandController { ? source.events.findLast(event => event.type === 'turn/end') : undefined) if (boundary === undefined) { - reject( - 'fork-unavailable', + throw new RemoteError( + 'session/fork-unavailable', atSeq !== undefined && atSeq <= lastSeq ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}` : `session "${request.sessionId}" has no completed turn to fork from`, @@ -230,8 +230,8 @@ export class SessionCommandController { try { workspace = await this.forkWorkspace(source.header) } catch (error) { - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, {}, ) @@ -255,8 +255,8 @@ export class SessionCommandController { setup: composition.setup, }) } catch (error) { - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `failed to fork session "${request.sessionId}": ${String(error)}`, {}, ) @@ -265,8 +265,8 @@ export class SessionCommandController { try { await workspace.attachSession(childId) } catch (error) { - reject( - 'workspace-attach-failed', + throw new RemoteError( + 'session/workspace-attach-failed', `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, { sessionId: childId, workspaceId: workspace.id }, ) @@ -285,8 +285,8 @@ export class SessionCommandController { ? undefined : canonicalClientTimeZone(request.clientTimeZone) if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { - reject( - 'invalid-time-zone', + throw new RemoteError( + 'session/invalid-time-zone', 'clientTimeZone must be UTC or a valid IANA Area/Location name', { value: request.clientTimeZone }, ) @@ -294,8 +294,8 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) const selection = this.agents.selectionFor(agent).current if (!routeServed(this.ctx, selection.provider)) { - reject( - 'model-unavailable', + throw new RemoteError( + 'session/model-unavailable', `no adapter serves provider "${selection.provider}"; select a model for this session`, { provider: selection.provider, model: selection.model }, ) @@ -312,8 +312,8 @@ export class SessionCommandController { const current = this.agents.selectionFor(agent).current const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { - reject( - 'attachment-error', + throw new RemoteError( + 'session/attachment-invalid', `Model "${current.model}" does not support image input.`, { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, ) @@ -324,11 +324,11 @@ export class SessionCommandController { if (request.mode === 'steer') agent.steer(message) else agent.followup(message) } catch (error) { - if (error instanceof TypertRemoteFailure) throw error + if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { - reject('attachment-error', error.message, { reason: error.code }) + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } - reject('agent-busy', 'prompt rejected', { reason: String(error) }) + throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) }) } return { accepted: true } } @@ -346,18 +346,18 @@ export class SessionCommandController { source = await this.readSessionState(request.sessionId) } catch (error) { if (error instanceof ApiSessionNotFound) { - reject('session-not-found', error.message, { sessionId: request.sessionId }) + throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }) } - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, {}, ) } const ref = referencedImage(source.events, String(request.attachmentId)) if (ref === undefined) { - reject( - 'attachment-error', + throw new RemoteError( + 'session/attachment-invalid', 'Image is not referenced by this session.', { reason: 'ATTACHMENT_NOT_REFERENCED' }, ) @@ -370,9 +370,9 @@ export class SessionCommandController { } } catch (error) { if (error instanceof AttachmentError) { - reject('attachment-error', error.message, { reason: error.code }) + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } - reject('internal', 'Unable to read image attachment.', {}) + throw new RemoteError('gateway/internal', 'Unable to read image attachment.', {}) } } @@ -384,18 +384,18 @@ export class SessionCommandController { updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { if (request.action.kind === 'edit' && request.action.content.some(block => block.type !== 'text')) { - reject( - 'attachment-error', + throw new RemoteError( + 'session/attachment-invalid', 'queue edits accept text content only', { reason: 'QUEUE_EDIT_NON_TEXT' }, ) } const agent = this.ctx.agents.get(request.sessionId) if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + throw apiSessionSubagentOwnershipError(request.sessionId) } if (agent === undefined) { - reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) @@ -403,11 +403,11 @@ export class SessionCommandController { ? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep } : { target: 'next-turn' as const, message: nextTurn } if (located === undefined) { - reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const { target, message } = located if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { - reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) + throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) } if (request.action.kind === 'edit') { agent.inbox.replace(request.itemId, freezeMessage({ @@ -429,14 +429,14 @@ export class SessionCommandController { cancel(request: SessionCancelRequest): SessionCancelValue { const agent = this.ctx.agents.get(request.sessionId) if (agent === undefined) { - reject( - 'session-not-found', + throw new RemoteError( + 'session/not-found', `session "${request.sessionId}" not found (not attached)`, { sessionId: request.sessionId }, ) } if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + throw apiSessionSubagentOwnershipError(request.sessionId) } agent.cancel({ kind: 'user' }, { keepInbox: true }) return { accepted: true } @@ -444,41 +444,30 @@ export class SessionCommandController { private async resolveAgent(sessionId: SessionId): Promise { const found = await this.agents.resolveAgent(sessionId) - if ('error' in found) rejectFailure(found.error) + if ('error' in found) throw found.error return found.agent } private rejectCreation(sessionId: SessionId, error: unknown): never { + if (remoteErrorOf(error) !== undefined) throw error if (error instanceof ApiSessionPresetConflict) { - reject('agent-preset-conflict', error.message, { + throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } - if (error instanceof UnknownPresetError) { - reject('agent-preset-not-found', error.message, { - agentPreset: error.presetId, - available: [...error.available], - }) - } - if (error instanceof PresetMountError) { - reject('agent-preset-invalid', error.message, { - agentPreset: error.presetId, - reason: error.reason, - }) - } if (error instanceof ApiSessionCwdConflict) { - reject('session-conflict', error.message, { + throw new RemoteError('session/conflict', error.message, { sessionId: error.sessionId, requestedCwd: error.requestedCwd, ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), }) } if (error instanceof ApiSessionSubagentOwnership) { - rejectFailure(apiSessionSubagentOwnershipError(error.sessionId)) + throw apiSessionSubagentOwnershipError(error.sessionId) } - reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { @@ -503,14 +492,6 @@ export class SessionCommandController { } } -function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never { - throw new TypertRemoteFailure(error) -} - -function reject(code: string, message: string, details: object): never { - throw new TypertRemoteFailure({ code, message, details }) -} - async function durablePromptContent( ctx: Context, content: readonly SessionPromptRequest['content'][number][], @@ -580,18 +561,6 @@ function referencedImage( return undefined } -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - -function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch { - return undefined - } -} - function routeServed(ctx: Context, provider: string): boolean { return ctx.llm.listProviders().some(entry => entry.id === provider) } diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index d78509e49b..ce32cbf3d5 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -6,7 +6,7 @@ import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-sessi import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAddress, SessionChunkRun, @@ -55,15 +55,15 @@ export class SessionHistoryController { const sourceLog = source.events const sourceCursor = sourceLog.at(-1)?.seq ?? -1 if (request.throughSeq > sourceCursor) { - reject( - 'bad-request', + throw new RemoteError( + 'gateway/bad-request', `session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`, {}, ) } /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) { - reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) + throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) } const page = paginate( sourceLog, @@ -155,7 +155,7 @@ export class SessionHistoryController { } if (item.seq < nextSeq) continue if (item.seq !== nextSeq) { - reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {}) + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {}) } nextSeq++ yield entryFor(item) @@ -211,22 +211,22 @@ function projectionBlock( function validatePageRequest(request: SessionPageRequest): void { if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) { - reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) } if (request.beforeSeq !== undefined && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) { - reject('bad-request', 'beforeSeq must be a non-negative safe integer', {}) + throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) } if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { - reject('bad-request', 'maxMessages must be a positive safe integer', {}) + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) } } function validateFollowRequest(request: SessionFollowRequest): void { if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { - reject('bad-request', 'maxMessages must be a positive safe integer', {}) + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) } } @@ -241,34 +241,34 @@ function validateAddress( ): void { if (address.kind === 'session') { if (header.origin === 'subagent') { - reject('agent-busy', 'subagent Sessions require their durable parent address', { + throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', { reason: 'use subagent delivery for this child session', }) } return } if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) { - reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', { + throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', { childSessionId: address.childSessionId, }) } const identity = projections?.values.subagent if (identity === null) { - reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', { + throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, reason: 'corrupt', }) } if (identity === undefined || identity.seq < (header.seedLength ?? 0)) { - reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', { + throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, reason: 'unsupported', }) } if (identity.mode !== address.mode) { - reject('subagent-unauthorized', 'subagent mode does not match the supplied address', { + throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) } @@ -276,18 +276,14 @@ function validateAddress( function rejectNotFound(address: SessionAddress): never { if (address.kind === 'session') { - reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId }) + throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId }) } - reject('subagent-not-found', 'subagent is unavailable', { + throw new RemoteError('subagent/not-found', 'subagent is unavailable', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, }) } -function reject(code: string, message: string, details: object): never { - throw new TypertRemoteFailure({ code, message, details }) -} - function paginate( events: readonly SessionEvent[], beforeSeq: number | undefined, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 342dd977eb..572f895754 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,7 +6,7 @@ import { errorChain } from '@deepseek-ai/dsh-llm' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { ApiSessionAgentController, inspectApiSession, @@ -264,7 +264,7 @@ export class SessionController extends TypertRemoteService { * @param request - path after best-effort Session workspace resolution. * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + * @throws RemoteError when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( @@ -272,27 +272,23 @@ export class SessionController extends TypertRemoteService { signal: AbortSignal, ): Promise { if (request.path.length === 0) { - throw new TypertRemoteFailure({ - code: 'bad-request', - message: 'session.openWorkspacePath requires a non-empty path', - details: {}, - }) + throw new RemoteError( + 'gateway/bad-request', + 'session.openWorkspacePath requires a non-empty path', + {}, + ) } signal.throwIfAborted() try { await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { - if (signal.aborted) { - throw new TypertRemoteFailure({ - code: 'cancelled', message: 'path open was aborted', details: {}, - }) - } - throw new TypertRemoteFailure({ - code: 'internal', - message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) + throw new RemoteError( + 'gateway/internal', + `path open failed: ${error instanceof Error ? error.message : String(error)}`, + {}, + ) } } diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 739a8baf31..ab72da1c92 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -8,7 +8,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek- import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { SESSION_SEARCH_RESULT_LIMIT, @@ -226,8 +226,8 @@ export class ApiSessionList { signal.throwIfAborted() const provider = this.ctx.get('sessionQuery') if (provider === undefined) { - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', {}, ) @@ -317,9 +317,9 @@ export class ApiSessionList { } catch (error: unknown) { signal.throwIfAborted() if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') { - reject('cancelled', 'session search was aborted', {}) + throw new RemoteError('gateway/cancelled', 'session search was aborted', {}) } - reject('internal', `session search failed: ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `session search failed: ${String(error)}`, {}) } } @@ -351,25 +351,21 @@ export class ApiSessionList { function normalizeSearchQuery(query: string): string { const normalized = query.trim() if (normalized.length === 0) { - reject('bad-request', 'session search query must not be empty', {}) + throw new RemoteError('gateway/bad-request', 'session search query must not be empty', {}) } if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) { - reject( - 'bad-request', + throw new RemoteError( + 'gateway/bad-request', `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`, {}, ) } if (normalized.includes('\0')) { - reject('bad-request', 'session search query must not contain NUL', {}) + throw new RemoteError('gateway/bad-request', 'session search query must not contain NUL', {}) } return normalized } -function reject(code: string, message: string, details: object): never { - throw new TypertRemoteFailure({ code, message, details }) -} - function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) } diff --git a/packages/api/session-controller/src/skill-catalog.ts b/packages/api/session-controller/src/skill-catalog.ts index 3cd15669ff..82043a4038 100644 --- a/packages/api/session-controller/src/skill-catalog.ts +++ b/packages/api/session-controller/src/skill-catalog.ts @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { ScopeKey } from '@deepseek-ai/dsh-scope' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { SkillListRequest, SkillListValue } from './types.ts' declare module '@deepseek-ai/cordis' { @@ -30,7 +30,7 @@ export class SessionSkillCatalog extends TypertRemoteService { * @param request - Session identity whose cwd and preset select the catalog view. * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. * @returns user-invocable skill metadata without loading skill bodies. - * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + * @throws RemoteError when the Session cannot be inspected or no registry can serve it. */ @Remote async list(request: SkillListRequest, signal: AbortSignal): Promise { @@ -48,19 +48,16 @@ export class SessionSkillCatalog extends TypertRemoteService { } catch (error: unknown) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - throw failure( - 'session-not-found', - `session "${sessionId}" not found`, - { sessionId }, - ) + throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId }) } - throw failure( - 'internal', + throw new RemoteError( + 'gateway/internal', `session "${sessionId}" could not be inspected: ${String(error)}`, + {}, ) } if (cwd === undefined) { - throw failure('internal', `session "${sessionId}" has no project cwd`) + throw new RemoteError('gateway/internal', `session "${sessionId}" has no project cwd`, {}) } const live = this.ctx.agents.get(sessionId) @@ -68,9 +65,10 @@ export class SessionSkillCatalog extends TypertRemoteService { const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') const skillRegistry = scoped ?? this.ctx.get('skills') if (skillRegistry === undefined) { - throw failure( - 'internal', + throw new RemoteError( + 'gateway/internal', 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', + {}, ) } @@ -86,7 +84,7 @@ export class SessionSkillCatalog extends TypertRemoteService { })), } } catch (error: unknown) { - throw failure('internal', `skill listing failed: ${String(error)}`) + throw new RemoteError('gateway/internal', `skill listing failed: ${String(error)}`, {}) } } @@ -108,13 +106,4 @@ export class SessionSkillCatalog extends TypertRemoteService { } } -/** Build one stable Remote failure with optional typed details. */ -function failure( - code: 'session-not-found' | 'internal', - message: string, - details: { readonly sessionId: SessionId } | Record = {}, -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} - export default SessionSkillCatalog diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 167937e3d3..7359d89004 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -174,55 +174,39 @@ export const SESSION_SEARCH_RESULT_LIMIT = 20 /** Maximum search snippet length in Unicode code points. */ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 -/** Error details returned by Session Remote methods. */ -export interface SessionErrorDetailsMap { - 'bad-request': Record - cancelled: Record - 'session-not-found': { readonly sessionId: SessionId } - 'model-unavailable': { readonly provider: string; readonly model: string } - 'session-conflict': { - readonly sessionId: SessionId - readonly requestedCwd: string - readonly existingCwd?: string +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'session/model-unavailable': { readonly provider: string; readonly model: string } + 'session/conflict': { + readonly sessionId: SessionId + readonly requestedCwd: string + readonly existingCwd?: string + } + 'session/agent-busy': { readonly reason: string } + 'session/invalid-time-zone': { readonly value: string } + 'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } + 'agent-preset/conflict': { + readonly sessionId: SessionId + readonly requestedPreset: string + readonly existingPreset?: string + } + 'session/attachment-invalid': { readonly reason: string } + 'session/queue-item-not-found': { readonly itemId: MessageId } + 'session/steer-unavailable': { readonly itemId: MessageId } + 'session/title-invalid': { readonly sessionId: SessionId } + 'session/fork-unavailable': { readonly sessionId: SessionId } + 'subagent/not-found': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + } + 'subagent/catalog-diagnostic': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } } - 'invalid-time-zone': { readonly value: string } - 'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } - 'workspace-not-found': { readonly workspaceId: string } - 'agent-preset-conflict': { - readonly sessionId: SessionId - readonly requestedPreset: string - readonly existingPreset?: string - } - 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } - 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } - 'agent-busy': { readonly reason: string } - 'attachment-error': { readonly reason: string } - 'queue-item-not-found': { readonly itemId: MessageId } - 'steer-unavailable': { readonly itemId: MessageId } - 'title-invalid': { readonly sessionId: SessionId } - 'fork-unavailable': { readonly sessionId: SessionId } - 'subagent-not-found': { - readonly parentSessionId: SessionId - readonly childSessionId: SessionId - } - 'subagent-catalog-diagnostic': { - readonly parentSessionId: SessionId - readonly childSessionId: SessionId - readonly reason: 'corrupt' | 'unsupported' | 'unavailable' - } - 'subagent-unauthorized': { readonly childSessionId: SessionId } - internal: Record } -/** Session business failure returned without throwing a carrier error. */ -export type SessionError = { - [Code in keyof SessionErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: SessionErrorDetailsMap[Code] - } -}[keyof SessionErrorDetailsMap] - /** Session-addressed request for the human-invocable skill catalog. */ export interface SkillListRequest { readonly sessionId: SessionId diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 3f4e721228..6bfea1609d 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -8,7 +8,6 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -154,7 +153,7 @@ describe('ApiSession Agent lookup and recovery', () => { header: header('observed-without-cwd', null), } as SessionObservation await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({ - error: { code: 'session-not-found' }, + error: { code: 'session/not-found' }, }) }) @@ -170,7 +169,7 @@ describe('ApiSession Agent lookup and recovery', () => { if (host === undefined) throw new Error('Agent Context resolver was not registered') await expect(host.resolve(live.id)).resolves.toBe(live.ctx) - await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure) + await expect(host.resolve(SessionId('missing'))).rejects.toMatchObject({ code: 'session/not-found' }) }) it('returns raced ordinary Agents and ownership failures after resume throws', async () => { @@ -200,7 +199,7 @@ describe('ApiSession Agent lookup and recovery', () => { throw new Error('raced child publication') }) await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ - error: { code: 'agent-busy' }, + error: { code: 'session/agent-busy' }, }) }) @@ -211,7 +210,7 @@ describe('ApiSession Agent lookup and recovery', () => { inspect: vi.fn(), }) await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({ - error: { code: 'session-not-found' }, + error: { code: 'session/not-found' }, }) const failed = await harness() @@ -222,7 +221,7 @@ describe('ApiSession Agent lookup and recovery', () => { }) vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ - error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string }, + error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string }, }) }) @@ -408,7 +407,7 @@ describe('ApiSession create or adoption', () => { mount: () => Promise.resolve(), } as never) await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ - error: { code: 'agent-busy' }, + error: { code: 'session/agent-busy' }, }) const conflict = await harness() diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 2ecaff6804..2f74ffe9a1 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -63,12 +63,14 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } - ctx.reflect.provide('connection', connection) ctx.reflect.provide('remote', { ...remote, $stream: (options: RemoteStreamOptions) => ( new RemoteStream(connection, options) ), + get $host() { + return { home: generation?.host.home, isLoopback: connection.isLoopback } + }, $on: (event: string, listener: RemoteListener) => { const eventListeners = listeners.get(event) ?? new Set() eventListeners.add(listener) 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 789fe04d6d..796ba1727c 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import { MutableSessionEventSource, type SessionLiveEventEntry, } from '../src/client/contract/events.ts' -import { transportResult } from '../src/client/contract/result.ts' function entry(seq: number): SessionLiveEventEntry { return { @@ -76,14 +75,4 @@ describe('Client Session contracts', () => { expect(iterate).toHaveBeenCalledOnce() }) - it('folds Error and non-Error carrier rejections into Client failures', () => { - expect(transportResult(new Error('transport unavailable'))).toEqual({ - ok: false, - error: { code: 'internal', message: 'transport unavailable', details: {} }, - }) - expect(transportResult(404)).toEqual({ - ok: false, - error: { code: 'internal', message: '404', details: {} }, - }) - }) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 88b1450970..42bb3fe508 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -1,9 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import { PresetMountError } from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-presets' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' import { describe, expect, it, vi } from 'vitest' import { @@ -14,7 +15,7 @@ import { SessionCommandController } from '../src/commands.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' async function expectFailure(operation: Promise, code: string): Promise { - await expect(operation).rejects.toMatchObject({ failure: { code } }) + await expect(operation).rejects.toMatchObject({ code }) } function controllerAgents(overrides: object = {}): ApiSessionAgentController { @@ -76,7 +77,7 @@ describe('Session creation failures', () => { ) await expectFailure(missingController.create({ workspaceId: 'missing' as WorkspaceId, - }), 'workspace-not-found') + }), 'workspace/not-found') await missing.fiber.dispose() const failed = await baseContext() @@ -97,26 +98,30 @@ describe('Session creation failures', () => { await expectFailure(failedController.create({ sessionId: SessionId('workspace-session'), workspaceId: workspace.id, - }), 'workspace-attach-failed') + }), 'session/workspace-attach-failed') await failed.fiber.dispose() }) it.each([ { - error: new PresetMountError('broken', 'invalid composition'), - code: 'agent-preset-invalid', + error: new RemoteError( + 'agent-preset/invalid', + 'agent-presets: preset "broken" failed to mount: invalid composition', + { agentPreset: 'broken', reason: 'invalid composition' }, + ), + code: 'agent-preset/invalid', }, { error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined), - code: 'session-conflict', + code: 'session/conflict', }, { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), - code: 'session-conflict', + code: 'session/conflict', }, { error: new Error('factory unavailable'), - code: 'internal', + code: 'gateway/internal', }, ])('maps $code creation failures', async ({ error, code }) => { const ctx = await baseContext() @@ -140,7 +145,7 @@ describe('Session creation failures', () => { await expectFailure(controller.create({ workspaceId: 'workspace-1' as WorkspaceId, cwd: '/workspace', - }), 'bad-request') + }), 'gateway/bad-request') await ctx.fiber.dispose() }) @@ -179,7 +184,7 @@ describe('Session fork failures', () => { ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), - }), 'session-not-found') + }), 'session/not-found') await withoutPersistence.fiber.dispose() const missing = await baseContext() @@ -191,7 +196,7 @@ describe('Session fork failures', () => { const missingController = new SessionCommandController(missing, controllerAgents(), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), - }), 'session-not-found') + }), 'session/not-found') await missing.fiber.dispose() }) @@ -201,7 +206,7 @@ describe('Session fork failures', () => { vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) const controller = new SessionCommandController(ctx, controllerAgents(), '/default') - await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal') + await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal') await ctx.fiber.dispose() }) @@ -211,7 +216,7 @@ describe('Session fork failures', () => { const source = ctx.sessions.create(SessionId('empty-source')) const controller = new SessionCommandController(ctx, controllerAgents(), '/default') - await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable') + await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable') await ctx.fiber.dispose() }) @@ -225,7 +230,7 @@ describe('Session fork failures', () => { origin: 'subagent', }) const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') - await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal') + await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal') await lineage.fiber.dispose() const creation = await baseContext() @@ -233,7 +238,7 @@ describe('Session fork failures', () => { const source = completedSession(creation, 'creation-source', '/workspace') vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) const creationController = new SessionCommandController(creation, controllerAgents(), '/default') - await expectFailure(creationController.fork({ sessionId: source.id }), 'internal') + await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal') await creation.fiber.dispose() }) @@ -251,7 +256,7 @@ describe('Session fork failures', () => { ) const controller = new SessionCommandController(ctx, controllerAgents(), '/default') - await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed') + await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed') const options = create.mock.calls[0]?.[0] if (options === undefined) throw new Error('Agent creation was not attempted') expect(options.meta).not.toHaveProperty('cwd') diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 2ea8744bd1..420a7d1bf0 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -56,7 +56,7 @@ async function commandHarness(): Promise<{ } async function expectFailure(operation: Promise, code: string): Promise { - await expect(operation).rejects.toMatchObject({ failure: { code } }) + await expect(operation).rejects.toMatchObject({ code }) } describe('Session queue commands', () => { @@ -79,21 +79,21 @@ describe('Session queue commands', () => { }, }], }, - })), 'attachment-error') + })), 'session/attachment-invalid') await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' }, - })), 'queue-item-not-found') + })), 'session/queue-item-not-found') await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' }, - })), 'queue-item-not-found') + })), 'session/queue-item-not-found') await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' }, - })), 'steer-unavailable') + })), 'session/steer-unavailable') Object.assign(agent, { status: 'idle' }) await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, - })), 'steer-unavailable') + })), 'session/steer-unavailable') expect(controller.updateQueue({ sessionId: agent.id, itemId: queued.id, @@ -114,7 +114,7 @@ describe('Session queue commands', () => { await expectFailure(Promise.resolve().then(() => controller.cancel({ sessionId: SessionId('missing'), - })), 'session-not-found') + })), 'session/not-found') expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true }) expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) await ctx.fiber.dispose() @@ -209,7 +209,7 @@ describe('Session attachment authorization', () => { ) await expectFailure(noPersistenceController.attachment({ sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), - }), 'session-not-found') + }), 'session/not-found') const missing = new Context() await missing.plugin(SessionStore) @@ -225,7 +225,7 @@ describe('Session attachment authorization', () => { ) await expectFailure(missingController.attachment({ sessionId: SessionId('missing'), attachmentId: 'att' as never, - }), 'session-not-found') + }), 'session/not-found') for (const thrown of [ new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'), @@ -239,7 +239,7 @@ describe('Session attachment authorization', () => { await expectFailure(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId, - }), thrown instanceof AttachmentError ? 'attachment-error' : 'internal') + }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal') await fixture.ctx.fiber.dispose() } }) @@ -257,7 +257,7 @@ describe('Session attachment authorization', () => { await expectFailure(controller.attachment({ sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'), - }), 'internal') + }), 'gateway/internal') await ctx.fiber.dispose() }) }) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 39a4bf1888..f31c2cb3c7 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import SessionController from '../src/index.ts' import type { ApiSessionAgentController } from '../src/agent.ts' @@ -124,7 +125,7 @@ describe('SessionController facade', () => { if (outcome === 'success') resolve.mockResolvedValue({ agent: live }) else if (outcome === 'domain-error') { resolve.mockResolvedValue({ - error: { code: 'internal', message: 'activation unavailable', details: {} }, + error: new RemoteError('gateway/internal', 'activation unavailable', {}), }) } else { resolve.mockRejectedValue(new Error('activation crashed')) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index cb6a635bef..e3ea48f783 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { MessageId, - RpcError, RpcResponse, SessionId, SessionSearchItem, + SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -24,10 +24,8 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStream, - RemoteStreamError, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' @@ -72,28 +70,21 @@ export function deferred(): Deferred { return { promise, resolve, reject } } -let nextRpc = 0 - -export function ok(value: T): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } -} - -export function err(error: RpcError): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } } -} - -/** Successful generated Remote result for programmable domain fakes. */ -export function remoteOk(value: T): RemoteResult { +/** + * Successful generated Remote result for programmable domain fakes. + * @param value - the value the Host answers with. + * @returns the success branch of a Remote result. + */ +export function ok(value: T): RemoteResult { return { ok: true, value } } /** - * Failed generated Remote result carrying an owner's own failure vocabulary, - * which the carrier's closed RPC code set does not contain. + * Failed generated Remote result carrying the owner's declared failure. * @param error - the owner-declared failure. * @returns the failure branch of a Remote result. */ -export function remoteErr(error: RemoteFailure): RemoteResult { +export function err(error: RemoteFailure): RemoteResult { return { ok: false, error } } @@ -129,11 +120,11 @@ export class FakeApiClient { readonly followStarts: SessionId[] = [] // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = + onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onSelectModel: (payload: SessionSelectModelRequest) => Promise> = + onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onSelectModel: (payload: SessionSelectModelRequest) => Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, @@ -143,19 +134,19 @@ export class FakeApiClient { : { reasoningEffort: payload.reasoningEffort }), }, })) - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ records: [], hasMore: false })) - onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onAttachment: (payload: unknown) => Promise> = + onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onOpenWorkspacePath: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ opened: true as const })) + () => Promise.resolve(ok({ opened: true as const })) private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] @@ -174,30 +165,30 @@ export class FakeApiClient { lastSearchSignal: AbortSignal | undefined onSubagentList: (payload: unknown) => Promise> - = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) + = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) onSubagentPrompt: (payload: unknown) => Promise> - = () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId })) + = () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId })) onSubagentInterrupt: (payload: unknown) => Promise> - = () => Promise.resolve(remoteOk({ accepted: true as const })) + = () => Promise.resolve(ok({ accepted: true as const })) onWorkspaceCreate: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) onWorkspaceRename: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) onWorkspaceDelete: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ deleted: true })) + () => Promise.resolve(ok({ deleted: true })) onWorkspaceInsertBefore: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspaceIds: [] })) + () => Promise.resolve(ok({ workspaceIds: [] })) onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) onWorkspaceArchiveSession: (payload: unknown) => Promise> = - payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) + payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ sessionRemotes(): RuntimeRemotes { @@ -209,8 +200,8 @@ export class FakeApiClient { execute: () => Promise.resolve({ ok: true, value: undefined }), }, session: { - canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)), - list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + canOpenWorkspacePath: () => Promise.resolve(ok(true)), + list: payload => this.record('session.list', payload, this.onList(payload)), modelCatalog: () => Promise.resolve({ ok: true, value: { @@ -222,20 +213,20 @@ export class FakeApiClient { }), search: (payload, signal) => { this.lastSearchSignal = signal - return this.remoteResult('session.search', payload, this.onSearch(payload)) + return this.record('session.search', payload, this.onSearch(payload)) }, - create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)), - selectModel: payload => this.remoteResult( + create: payload => this.record('session.create', payload, this.onCreate(payload)), + selectModel: payload => this.record( 'session.selectModel', payload, this.onSelectModel(payload), ), - rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)), - fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)), - prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)), - attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), - updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + rename: payload => this.record('session.rename', payload, this.onRename(payload)), + fork: payload => this.record('session.fork', payload, this.onFork(payload)), + prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), + updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), + cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), openWorkspacePath: payload => this.record( 'session.openWorkspacePath', payload, @@ -333,21 +324,13 @@ export class FakeApiClient { return response } - private async remoteResult( - method: string, - payload: unknown, - response: Promise>, - ): Promise> { - return (await this.record(method, payload, response)).result - } - private page(request: SessionPageRequest): Promise> { return this.fetchPage(request) } private async fetchPage( request: SessionPageRequest, - response?: Promise>, + response?: Promise>, ): Promise> { const sessionId = addressSessionId(request.address) const payload = request.address.kind === 'session' @@ -366,7 +349,7 @@ export class FakeApiClient { ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, } const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history' - const result = await this.remoteResult(method, payload, response ?? this.onHistory({ + const result = await this.record(method, payload, response ?? this.onHistory({ sessionId, throughSeq: request.throughSeq, ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, @@ -398,14 +381,8 @@ export class FakeApiClient { sessionId, maxMessages: request.maxMessages ?? 50, }) - if (!response.result.ok) { - throw new RemoteStreamError( - response.result.error.code, - response.result.error.message, - response.result.error.details, - ) - } - const page = response.result.value + if (!response.ok) throw response.error + const page = response.value const tail = page.records.at(-1) const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail)) yield { diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 8b311de746..541c104a58 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -5,10 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId @@ -92,10 +93,10 @@ describe('list lifecycle', () => { it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() - api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) + api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'boom', {}))) const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() - expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } }) // A failed pull does not step the arrival phase: still pending. expect(manager.getListSnapshot().phase).toBe('pending') }) @@ -108,7 +109,7 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().phase).toBe('ready') // Sticky across later failures: the pull-activity axis reports the error, // the arrival phase holds. - api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'down', {}))) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) // And across an empty re-pull (empty-with-ready = truly no sessions). @@ -227,25 +228,18 @@ describe('search', () => { expect(api.lastSearchSignal).toBe(signal) }) - it('preserves business errors and folds transport failures', async () => { + it('preserves business errors and propagates a non-Remote throw', async () => { const api = new FakeApiClient() const manager = new SessionManager(fakeRemote(api)) - api.onSearch = () => Promise.resolve(err({ - code: 'internal', - message: 'index unavailable', - details: {}, - })) + api.onSearch = () => Promise.resolve(err(new RemoteError('gateway/internal', 'index unavailable', {}))) const signal = new AbortController().signal await expect(manager.search('first', signal)).resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'index unavailable' }, + error: { code: 'gateway/internal', message: 'index unavailable' }, }) api.onSearch = () => Promise.reject(new Error('wire down')) - await expect(manager.search('second', signal)).resolves.toMatchObject({ - ok: false, - error: { code: 'internal', message: 'wire down' }, - }) + await expect(manager.search('second', signal)).rejects.toThrow('wire down') }) }) @@ -279,7 +273,7 @@ describe('subagent catalogs', () => { summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, @@ -375,7 +369,7 @@ describe('subagent catalogs', () => { it('marks a loaded parent row expandable only for a direct subagent publication', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'parent', @@ -413,7 +407,7 @@ describe('subagent catalogs', () => { manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { parentSessionId: S1, origin: 'subagent', })) - response.resolve(remoteOk({ + response.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -426,7 +420,7 @@ describe('subagent catalogs', () => { { kind: 'child', id: S1, hasChildren: true }, ]) - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -449,7 +443,7 @@ describe('subagent catalogs', () => { manager.handleSessionStatus(S1, false) manager.handleSessionStatus(S2, true) - response.resolve(remoteOk({ + response.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'stopped', @@ -472,7 +466,7 @@ describe('subagent catalogs', () => { it('marks a detached catalog child inactive without requiring a selected address', async () => { const api = new FakeApiClient() - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, @@ -498,8 +492,8 @@ describe('subagent catalogs', () => { const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) - api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) - first.resolve(remoteOk({ entries: [], parentAvailable: true })) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) await refresh expect(api.callsOf('subagents.list')).toHaveLength(1) @@ -524,7 +518,7 @@ describe('subagent catalogs', () => { manager.handleSessionAdded(summary(S2, { parentSessionId: root })) await vi.advanceTimersByTimeAsync(50) api.onSubagentList = () => second.promise - first.resolve(remoteOk({ + first.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'older', activity: 'inactive', hasChildren: false, @@ -533,7 +527,7 @@ describe('subagent catalogs', () => { })) await refresh // The trailing pull is already in flight (kicked synchronously in finally). - second.resolve(remoteOk({ + second.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'older', @@ -571,7 +565,7 @@ describe('subagent catalogs', () => { api.onSubagentList = () => first.promise const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshSubagents(root) - first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) @@ -583,12 +577,12 @@ describe('subagent catalogs', () => { manager.handleSessionRemoved(root) const trailing = deferred>>() api.onSubagentList = () => trailing.promise - mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) + mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) - trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} })) + trailing.resolve(err(new RemoteError('gateway/internal', 'trailing pull failed', {}))) await vi.waitFor(() => { expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ state: 'error', @@ -605,7 +599,7 @@ describe('subagent catalogs', () => { it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false, @@ -625,12 +619,11 @@ describe('subagent catalogs', () => { }) describe('remaining branches', () => { - it('refreshList folds a transport throw into the error state', async () => { + it('refreshList propagates a non-Remote throw', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) const manager = new SessionManager(fakeRemote(api)) - await manager.refreshList() - expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) + await expect(manager.refreshList()).rejects.toThrow('list wire down') }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { @@ -652,36 +645,32 @@ describe('remaining branches', () => { await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) api.onCreate = () => Promise.reject(new Error('create wire down')) - expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } }) + await expect(manager.create()).rejects.toThrow('create wire down') // Business error passes through untouched. - api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} })) + api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', 'no', {}))) expect(await manager.create()).toMatchObject({ ok: false }) }) it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { const api = new FakeApiClient() - api.onCreate = () => Promise.resolve(err({ - code: 'workspace-attach-failed', - message: 'published but unattached', - details: { sessionId: S1, workspaceId: 'w1' }, - } as never)) + api.onCreate = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { + sessionId: S1, workspaceId: 'w1', + }))) const manager = new SessionManager(fakeRemote(api)) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) - expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) it('reconciles a fork child published before workspace attachment fails', async () => { const api = new FakeApiClient() - api.onFork = () => Promise.resolve(err({ - code: 'workspace-attach-failed', - message: 'forked but unattached', - details: { sessionId: S2, workspaceId: 'w1' }, - } as never)) + api.onFork = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', { + sessionId: S2, workspaceId: 'w1', + }))) const manager = new SessionManager(fakeRemote(api)) const result = await manager.fork({ sessionId: S1 }) - expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S2, parentSessionId: S1, @@ -693,8 +682,8 @@ describe('remaining branches', () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) const manager = new SessionManager(fakeRemote(api)) - const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) - expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) + await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 })) + .rejects.toThrow('response lost') expect(manager.getListSnapshot().items).toEqual([]) manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) @@ -790,8 +779,8 @@ describe('connected generation', () => { manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) - parent.resolve(remoteOk({ entries: [], parentAvailable: true })) - child.resolve(remoteOk({ entries: [], parentAvailable: true })) + parent.resolve(ok({ entries: [], parentAvailable: true })) + child.resolve(ok({ entries: [], parentAvailable: true })) await vi.waitFor(() => { expect(api.callsOf('session.list')).toHaveLength(1) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7156ac569d..bda4b6c962 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -13,7 +13,6 @@ import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -493,17 +492,13 @@ describe('Remote Agent and Session lookup policy', () => { const sessionLookup = ctx.typert.lookups.get('session') if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') const ownershipFailure = { - failure: { - code: 'agent-busy', - details: { reason: 'use subagent delivery for this child session' }, - }, + code: 'session/agent-busy', + details: { reason: 'use subagent delivery for this child session' }, } const coldFailure = Promise.resolve(agentLookup.resolve(coldId)) const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id)) - await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure) await expect(coldFailure).rejects.toMatchObject(ownershipFailure) - await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure) await expect(liveFailure).rejects.toMatchObject(ownershipFailure) expect(resume).not.toHaveBeenCalled() expect(inspect).toHaveBeenCalledOnce() @@ -576,14 +571,14 @@ describe('subagent ownership fence', () => { expect(prompt.ok).toBe(false) if (!prompt.ok) { expect(prompt.error).toMatchObject({ - code: 'agent-busy', + code: 'session/agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) } const create = await remote.create(request({ sessionId, cwd: '/proj' })) expect(create.ok).toBe(false) - if (!create.ok) expect(create.error.code).toBe('agent-busy') + if (!create.ok) expect(create.error.code).toBe('session/agent-busy') expect(resume).not.toHaveBeenCalled() expect(ctx.agents.get(sessionId)).toBeUndefined() expect(inspect).toHaveBeenCalledTimes(3) @@ -626,7 +621,7 @@ describe('subagent ownership fence', () => { })) expect(resume).toHaveBeenCalledTimes(1) expect(prompt.ok).toBe(false) - if (!prompt.ok) expect(prompt.error.code).toBe('internal') + if (!prompt.ok) expect(prompt.error.code).toBe('gateway/internal') }) it('rejects origin-marked and runtime-owned live children from generic controls', async () => { @@ -661,7 +656,7 @@ describe('subagent ownership fence', () => { const stopped = await remote.cancel(request({ sessionId: originChild.id })) expect(stopped.ok).toBe(false) - if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy') + if (!stopped.ok) expect(stopped.error.code).toBe('session/agent-busy') expect(cancel).not.toHaveBeenCalled() const queued = await remote.updateQueue(request({ @@ -670,7 +665,7 @@ describe('subagent ownership fence', () => { action: { kind: 'remove' }, })) expect(queued.ok).toBe(false) - if (!queued.ok) expect(queued.error.code).toBe('agent-busy') + if (!queued.ok) expect(queued.error.code).toBe('session/agent-busy') expect(updateInbox).not.toHaveBeenCalled() const selection = await remote.selectModel(request({ @@ -679,11 +674,11 @@ describe('subagent ownership fence', () => { model: 'm', })) expect(selection.ok).toBe(false) - if (!selection.ok) expect(selection.error.code).toBe('agent-busy') + if (!selection.ok) expect(selection.error.code).toBe('session/agent-busy') const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' })) expect(create.ok).toBe(false) - if (!create.ok) expect(create.error.code).toBe('agent-busy') + if (!create.ok) expect(create.error.code).toBe('session/agent-busy') expect(ctx.agents.get(originChild.id)).toBe(originChild) }) @@ -770,10 +765,10 @@ describe('subagent ownership fence', () => { content: [{ type: 'text' as const, text: 'invalid zone' }], clientTimeZone, })) - expect(invalid).toEqual({ + expect(invalid).toMatchObject({ ok: false, error: { - code: 'invalid-time-zone', + code: 'session/invalid-time-zone', message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', details: { value: clientTimeZone }, }, @@ -801,7 +796,7 @@ describe('degenerate composition (no persistence, no factory)', () => { }) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('session-not-found') + expect(response.error.code).toBe('session/not-found') } }) @@ -821,7 +816,7 @@ describe('degenerate composition (no persistence, no factory)', () => { throughSeq: -1, }) expect(response.ok).toBe(false) - if (!response.ok) expect(response.error.code).toBe('session-not-found') + if (!response.ok) expect(response.error.code).toBe('session/not-found') expect(inspect).toHaveBeenCalledOnce() }) }) @@ -850,7 +845,7 @@ describe('sessions.prompt synchronous rejection', () => { })) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('agent-busy') + expect(response.error.code).toBe('session/agent-busy') expect(response.error.message).toBe('prompt rejected') expect(response.error.details).toEqual({ reason: 'Error: agent "session-throwing" lifecycle disposed', @@ -891,7 +886,7 @@ describe('sessions.prompt synchronous rejection', () => { expect(selection.ok).toBe(false) if (!selection.ok) { expect(selection.error).toMatchObject({ - code: 'agent-busy', + code: 'session/agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) } diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 06e7b5a3dd..86fb18c16f 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -216,7 +216,7 @@ describe('sessions.fork', () => { for (const atSeq of [-1, 0.5]) { await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) - .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } }) } expect(ctx.sessions.list()).toEqual([]) await ctx.fiber.dispose() @@ -246,7 +246,7 @@ describe('sessions.fork', () => { const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ ok: false, - error: { code: 'fork-unavailable', details: { sessionId: source.id } }, + error: { code: 'session/fork-unavailable', details: { sessionId: source.id } }, }) if (!response.ok) expect(response.error.message).toMatch(/has not completed/) await ctx.fiber.dispose() diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index bc67a94ca6..f0d208e821 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -22,7 +22,7 @@ import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { ApiSessionAgentController } from '../src/agent.ts' import { buildModelCatalog } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { createSessionTestRemote } from './test-remote.ts' function request

(payload: P): P { @@ -110,11 +110,7 @@ async function harness(logged?: { 'Remote Rejected', [], undefined, - new TypertRemoteFailure({ - code: 'fixture-rejected', - message: 'fixture rejected the selection', - details: { provider: 'remote-rejected' }, - }), + new RemoteError('gateway/internal', 'fixture rejected the selection', {}), )) ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', [])) ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [ @@ -225,7 +221,7 @@ describe('Web session model selection', () => { })) expect(denied).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'TOO_MANY_IMAGES' } }, }) expect(saveImage).toHaveBeenCalledTimes(2) await ctx.fiber.dispose() @@ -294,7 +290,7 @@ describe('Web session model selection', () => { })) expect(denied).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, }) expect(readImage).toHaveBeenCalledOnce() await ctx.fiber.dispose() @@ -423,7 +419,7 @@ describe('Web session model selection', () => { expect(unsupported).toMatchObject({ ok: false, error: { - code: 'model-unavailable', + code: 'session/model-unavailable', message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"', }, }) @@ -433,10 +429,10 @@ describe('Web session model selection', () => { provider: 'missing', model: 'model', })) - expect(rejected).toEqual({ + expect(rejected).toMatchObject({ ok: false, error: { - code: 'model-unavailable', + code: 'session/model-unavailable', message: 'no adapter registered for provider "missing"', details: { provider: 'missing', model: 'model' }, }, @@ -445,12 +441,12 @@ describe('Web session model selection', () => { sessionId, provider: 'remote-rejected', model: 'model', - }))).toEqual({ + }))).toMatchObject({ ok: false, error: { - code: 'fixture-rejected', + code: 'gateway/internal', message: 'fixture rejected the selection', - details: { provider: 'remote-rejected' }, + details: {}, }, }) expect(currentSelection(ctx, sessionId)) @@ -561,7 +557,7 @@ describe('Web session model selection', () => { })) expect(refused).toMatchObject({ ok: false, - error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, + error: { code: 'session/model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, }) const unavailableCatalog = await buildModelCatalog(ctx) expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) @@ -621,9 +617,7 @@ describe('Web session model selection', () => { saveImages: () => { if (saveMode === 'error') return Promise.reject(new Error('image store offline')) if (saveMode === 'remote') { - return Promise.reject(new TypertRemoteFailure({ - code: 'fixture-rejected', message: 'fixture rejected', details: {}, - })) + return Promise.reject(new RemoteError('gateway/internal', 'fixture rejected', {})) } return Promise.resolve([savedRef]) }, @@ -643,7 +637,7 @@ describe('Web session model selection', () => { sessionId, mode: 'queue', content: [image], }))).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, }) expectValue(await remote.selectModel(request({ @@ -653,17 +647,17 @@ describe('Web session model selection', () => { sessionId, mode: 'queue', content: [{ ...image, data: '' }], }))).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'INVALID_IMAGE_BASE64' } }, }) saveMode = 'error' expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image], - }))).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + }))).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) saveMode = 'remote' expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image], - }))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } }) + }))).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'fixture rejected' } }) saveMode = 'success' expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] }))) expect(followup).toHaveBeenCalledOnce() @@ -681,13 +675,13 @@ describe('Web session model selection', () => { expect(await remote.selectModel(request({ sessionId, provider: 'metadata-broken', model: 'broken', }))).toMatchObject({ - ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' }, + ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' }, }) expect(await remote.selectModel(request({ sessionId, provider: 'string-error', model: 'broken', }))).toMatchObject({ ok: false, - error: { code: 'model-unavailable', message: 'string selection failure' }, + error: { code: 'session/model-unavailable', message: 'string selection failure' }, }) await ctx.fiber.dispose() }) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 2c1feb292f..ee199ddcdd 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -88,7 +88,7 @@ describe('session/openWorkspacePath', () => { }) await expect(remote.openWorkspacePath({ path: '' })) - .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } }) expect(openPath).not.toHaveBeenCalled() }) @@ -105,13 +105,13 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'path open failed: desktop unavailable' }, + error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' }, }) const aborted = new AbortController() - aborted.abort(new Error('cancelled')) + aborted.abort(new Error('gateway/cancelled')) await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal)) - .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) }) it('classifies opener cancellation and non-Error failures', async () => { @@ -119,7 +119,7 @@ describe('session/openWorkspacePath', () => { const aborted = new AbortController() const openPath = vi.fn() .mockImplementationOnce(async () => { - aborted.abort(new Error('cancelled')) + aborted.abort(new Error('gateway/cancelled')) throw new Error('opening stopped') }) .mockRejectedValueOnce('desktop unavailable') @@ -130,11 +130,11 @@ describe('session/openWorkspacePath', () => { }) await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) await expect(controller.openWorkspacePath({ path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ - failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, + code: 'gateway/internal', message: 'path open failed: desktop unavailable', }) }) }) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 261bddbb78..6805745666 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' @@ -98,7 +99,7 @@ describe('beginSubmission', () => { describe('prompt-coupled retirement', () => { it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ text: '失败的', @@ -121,7 +122,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) session.beginSubmission({ text: '还在', images: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 10ce0e4269..a30c72c86b 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -6,9 +6,10 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it } from 'vitest' import { createSessionTestRemote } from './test-remote.ts' @@ -26,7 +27,13 @@ function roster(ids: readonly string[]): unknown { defaultId: ids[0], resolve: (id?: string) => { const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + if (!ids.includes(wanted)) { + return Promise.reject(new RemoteError( + 'agent-preset/not-found', + `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`, + { agentPreset: wanted, available: ids }, + )) + } return Promise.resolve(presetOf(wanted)) }, mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), @@ -91,7 +98,7 @@ describe('session.create Agent preset identity', () => { const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) - expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } }) + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) }) it('refuses to adopt a live Session under a different preset', async () => { @@ -103,7 +110,7 @@ describe('session.create Agent preset identity', () => { expect(response).toMatchObject({ ok: false, error: { - code: 'agent-preset-conflict', + code: 'agent-preset/conflict', details: { sessionId: 's4', requestedPreset: 'standard', @@ -153,7 +160,7 @@ describe('session.create Agent preset identity', () => { expect(response).toMatchObject({ ok: false, error: { - code: 'agent-preset-conflict', + code: 'agent-preset/conflict', details: { sessionId: 's7', requestedPreset: 'standard', diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index b1c54602e5..08d2056d54 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -90,7 +90,7 @@ describe('sessions.rename', () => { expect(response.ok).toBe(false) if (!response.ok) { expect(response.error).toMatchObject({ - code: 'title-invalid', + code: 'session/title-invalid', details: { sessionId: source.id }, }) // The message renders verbatim in the rename dialog's alert. @@ -109,7 +109,7 @@ describe('sessions.rename', () => { const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) expect(response.ok).toBe(false) - if (!response.ok) expect(response.error.code).toBe('internal') + if (!response.ok) expect(response.error.code).toBe('gateway/internal') }) it('answers internal when the composition mounts no session-title service', async () => { @@ -119,7 +119,7 @@ describe('sessions.rename', () => { const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('internal') + expect(response.error.code).toBe('gateway/internal') expect(response.error.message).toMatch(/mounts no session-title service/) } }) diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index 02d22b4f46..0c4591556c 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -98,7 +98,7 @@ describe('session.search', () => { const list = new ApiSessionList(ctx, 0) await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ - failure: { code: 'internal' }, + code: 'gateway/internal', }) await ctx.fiber.dispose() }) @@ -191,7 +191,7 @@ describe('session.search', () => { for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { await expect(remote.search(request(query), new AbortController().signal)) - .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } }) } expect(searchSessions).not.toHaveBeenCalled() await ctx.fiber.dispose() @@ -353,7 +353,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('100-call work budget') expect(searchSessions).toHaveBeenCalledTimes(100) }) @@ -457,7 +457,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error.code).toBe('internal') + expect(response.error.code).toBe('gateway/internal') expect(response.error.message).toContain('100-call work budget') expect(response).not.toHaveProperty('value') expect(searchSessions).toHaveBeenCalledTimes(100) @@ -486,7 +486,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -507,7 +507,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(response).not.toHaveProperty('value') expect(searchSessions).toHaveBeenCalledOnce() @@ -531,7 +531,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(searchSessions).toHaveBeenCalledTimes(2) expect(searchSessions.mock.calls.map(([providerRequest]) => ( @@ -558,7 +558,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit)) .toEqual([20, 10, 5, 2, 1]) @@ -584,7 +584,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(searchSessions).toHaveBeenCalledOnce() }) @@ -603,7 +603,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('returned 21 items; maximum is 20') }) @@ -629,7 +629,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('returned 11 items; maximum is 10') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -677,7 +677,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('repeated a continuation cursor') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -700,7 +700,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(response).not.toHaveProperty('value') if (response.ok) throw new Error('unreachable') @@ -755,7 +755,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(searchSessions).toHaveBeenCalledTimes(2) for (const call of searchSessions.mock.calls) { @@ -821,7 +821,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(list).toHaveBeenCalledOnce() expect(locateCalls).toBe(0) @@ -863,7 +863,7 @@ describe('session.search', () => { ) expect(cancelledBeforeLookup).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) const ctx = await baseContext() @@ -881,7 +881,7 @@ describe('session.search', () => { ) expect(cancelled).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) const failed = await remote.search( @@ -890,7 +890,7 @@ describe('session.search', () => { ) expect(failed.ok).toBe(false) if (failed.ok) throw new Error('unreachable') - expect(failed.error.code).toBe('internal') + expect(failed.error.code).toBe('gateway/internal') expect(failed.error.message).toContain('database unavailable') }) }) diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index 5b5e1b2a5a..6fe4169b72 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -161,9 +161,9 @@ describe('SessionSkillCatalog', () => { 'session "missing-skills" not found', 'SESSION_QUERY_SESSION_NOT_FOUND', ), - code: 'session-not-found', + code: 'session/not-found', }, - { error: new Error('storage offline'), code: 'internal' }, + { error: new Error('storage offline'), code: 'gateway/internal' }, ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => { const ctx = await context() ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never) @@ -172,7 +172,7 @@ describe('SessionSkillCatalog', () => { await expect(catalog.list( { sessionId: SessionId('missing-skills') }, new AbortController().signal, - )).rejects.toMatchObject({ failure: { code } }) + )).rejects.toMatchObject({ code }) }) it('reports an absent skill registry instead of an empty catalog', async () => { @@ -184,7 +184,7 @@ describe('SessionSkillCatalog', () => { const catalog = new SessionSkillCatalog(ctx) const failed = catalog.list({ sessionId }, new AbortController().signal) - await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failed).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(failed).rejects.toThrow('skill registry is absent') }) @@ -199,10 +199,10 @@ describe('SessionSkillCatalog', () => { const catalog = new SessionSkillCatalog(ctx) const unprojected = catalog.list({ sessionId }, new AbortController().signal) - await expect(unprojected).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(unprojected).rejects.toThrow('projected Session observation') const cwdless = catalog.list({ sessionId }, new AbortController().signal) - await expect(cwdless).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(cwdless).rejects.toThrow('has no project cwd') }) @@ -219,7 +219,7 @@ describe('SessionSkillCatalog', () => { await expect(catalog.list({ sessionId }, new AbortController().signal)) .rejects.toMatchObject({ - failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' }, + code: 'gateway/internal', message: 'skill listing failed: Error: catalog offline', }) }) }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 150e6813d1..f59ed3c287 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,11 +1,11 @@ /** Session object lifecycle, event-window transport, commands, and resync behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session, type SessionOptions } from '../src/client/sessions/session.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' const SID = 'fk-s1' as SessionId @@ -76,21 +76,19 @@ describe('Session open', () => { expect(api.callsOf('session.history')).toEqual([]) }) - it('lands an error result in openState=error with the RpcError kept', async () => { + it('lands an error result in openState=error with the Remote failure kept', async () => { const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } })) + api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID }))) await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') - expect(snapshot.openError?.code).toBe('session-not-found') + expect(snapshot.openError?.code).toBe('session/not-found') }) - it('folds a transport throw into openState=error / internal', async () => { + it('propagates a non-Remote throw raised while opening', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) - await session.open() - expect(session.getSnapshot().openState).toBe('error') - expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' }) + await expect(session.open()).rejects.toThrow('socket died') }) it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { @@ -283,23 +281,24 @@ describe('prompt and cancel errors', () => { it('lands an interrupt business failure in promptError with op=stop', async () => { const api = new FakeApiClient() - api.onSubagentInterrupt = () => Promise.resolve(remoteErr({ - code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, - })) + api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) await session.open() const cancelled = await session.cancel() - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } }) expect(session.getSnapshot().promptError).toMatchObject({ - op: 'stop', error: { code: 'subagent-unauthorized' }, + op: 'stop', error: { code: 'subagent/unauthorized' }, }) }) - it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { + it('sends a one-shot address to the Host under the continuable marker', async () => { const api = new FakeApiClient() + api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( + 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID }, + ))) const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, }) @@ -307,8 +306,15 @@ describe('prompt and cancel errors', () => { const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const cancelled = await session.cancel() - expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) + // The Host reads the durable descriptor; the wire marker stays 'continuable'. + expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } }) + expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('subagents.prompt')).toMatchObject([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + ]) + expect(api.callsOf('subagents.interruptByParent')).toEqual([ + { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, + ]) expect(api.callsOf('session.follow')).toEqual([ { address: { @@ -318,11 +324,35 @@ describe('prompt and cancel errors', () => { }, ]) expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagents.prompt')).toEqual([]) - expect(api.callsOf('subagents.interruptByParent')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) }) + it('delivers an image continuation to the Host, which refuses it', async () => { + const api = new FakeApiClient() + api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( + 'subagent/attachment-unsupported', + 'subagent continuation does not accept images', + { childSessionId: SID, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + ))) + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + }) + await session.open() + const prompted = await session.prompt( + [{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }], + 'queue', + ) + + expect(prompted).toMatchObject({ + ok: false, + error: { code: 'subagent/attachment-unsupported', details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' } }, + }) + // The image reaches the wire unfiltered: refusing it is the Host's call. + expect(api.callsOf('subagents.prompt')).toMatchObject([ + { content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] }, + ]) + }) + it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => { const { api, session } = makeSession() session.handleBlank(true) @@ -351,21 +381,20 @@ describe('prompt and cancel errors', () => { it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => { const { api, session } = makeSession() session.handleBlank(true) - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) + api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } }) expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: true, awaitingFirstTurn: true, }) }) - it('lands cancel failures in promptError with op=stop', async () => { + it('propagates a non-Remote throw raised while cancelling', async () => { const { api, session } = makeSession() api.onCancel = () => Promise.reject(new Error('cancel transport down')) - const result = await session.cancel() - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) + await expect(session.cancel()).rejects.toThrow('cancel transport down') + expect(session.getSnapshot().promptError).toBeNull() }) it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { @@ -400,32 +429,28 @@ describe('rename', () => { it('returns the business error untouched and folds a transport throw to internal', async () => { const { api, session } = makeSession() - api.onRename = () => Promise.resolve(err({ - code: 'title-invalid', message: 'empty', details: { sessionId: SID }, - } as never)) + api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID }))) const rejected = await session.rename(' ') - expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } }) + expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } }) expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() api.onRename = () => Promise.reject(new Error('rename transport down')) - const folded = await session.rename('x') - expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } }) + await expect(session.rename('x')).rejects.toThrow('rename transport down') }) }) describe('remaining branches', () => { - it('prompt transport throw folds to internal promptError', async () => { + it('propagates a non-Remote throw raised while prompting', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.reject(new Error('prompt wire down')) - const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue') - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } }) + await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down') + expect(session.getSnapshot().promptError).toBeNull() }) it('cancel business error also lands op=stop promptError', async () => { const { api, session } = makeSession() - api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } })) + api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' }))) await session.cancel() - expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } }) }) it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { @@ -435,7 +460,7 @@ describe('remaining branches', () => { api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) await session.open() // err result: window unchanged - api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) await session.loadOlder() expect(eventSeqs(session)).toHaveLength(6) expect(session.getSnapshot().hasMore).toBe(true) @@ -490,7 +515,7 @@ describe('remaining branches', () => { const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') expect(snapshot.openError).toMatchObject({ - code: 'internal', message: 'session event stream page did not end at its requested cursor', + code: 'gateway/internal', message: 'session event stream page did not end at its requested cursor', }) expect(eventSeqs(session)).toEqual([]) }) @@ -508,7 +533,7 @@ describe('remaining branches', () => { const { api, session } = makeSession() await follow(api, ev.user(0, '冷态帧')) expect(eventSeqs(session)).toEqual([]) - api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) await session.open() await follow(api, ev.user(0, '错态帧')) expect(eventSeqs(session)).toEqual([]) @@ -518,16 +543,14 @@ describe('remaining branches', () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() - const failure = { - code: 'session-not-found', - message: 'session disappeared', - details: { sessionId: SID }, - } + const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID }) - api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details)) + api.failStreams(failure) await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) - expect(session.getSnapshot().openError).toEqual(failure) + expect(session.getSnapshot().openError).toMatchObject({ + code: failure.code, message: failure.message, details: failure.details, + }) }) it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { @@ -545,10 +568,10 @@ describe('remaining branches', () => { follow(api, ev.user(10, '洞二')), ]) await vi.waitFor(() => { expect(repairs).toBe(1) }) - gate.reject(new Error('repair wire down')) + gate.reject(new RemoteError('gateway/internal', 'repair wire down', {})) await deliveries await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) - expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' }) + expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' }) expect(eventSeqs(session)).toHaveLength(6) }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 4e4bade67c..c98ff399fb 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' import type { SessionFollowFrame } from '../src/types.ts' @@ -18,7 +19,6 @@ import { err, fakeRemote, ok, - remoteOk, type RuntimeRemotes, } from './fake-api.client.ts' @@ -525,7 +525,7 @@ describe('catalog-addressed navigation', () => { b.api.onSubagentList = (payload) => { const parentSessionId = payload as SessionId if (parentSessionId === sid('root')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: true, @@ -534,7 +534,7 @@ describe('catalog-addressed navigation', () => { })) } if (parentSessionId === sid('child')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', activity: 'inactive', hasChildren: false, @@ -542,7 +542,7 @@ describe('catalog-addressed navigation', () => { parentAvailable: false, })) } - return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) + return Promise.resolve(ok({ entries: [], parentAvailable: false })) } await feedList(b, [ { id: 'root' }, @@ -564,7 +564,7 @@ describe('catalog-addressed navigation', () => { b.api.onSubagentList = (payload) => { const parentSessionId = payload as SessionId if (parentSessionId === sid('root')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: true, @@ -573,7 +573,7 @@ describe('catalog-addressed navigation', () => { })) } if (parentSessionId === sid('child')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', activity: 'inactive', hasChildren: false, @@ -581,7 +581,7 @@ describe('catalog-addressed navigation', () => { parentAvailable: false, })) } - return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) + return Promise.resolve(ok({ entries: [], parentAvailable: false })) } await feedList(b, [{ id: 'root' }]) await b.svc.refreshSubagents(sid('root')) @@ -611,15 +611,12 @@ describe('create', () => { b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) - b.api.onCreate = () => Promise.resolve({ - rpcId: 'e' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } }, - } as never) + b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {}))) const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ requestedSessionId: 'candidate', - rpcError: { code: 'internal', message: '爆了' }, + rpcError: { code: 'gateway/internal', message: '爆了' }, }) }) @@ -637,16 +634,11 @@ describe('create', () => { it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => { const b = bench() - b.api.onCreate = () => Promise.resolve({ - rpcId: 'attach' as never, - result: { - ok: false, - error: { - code: 'workspace-attach-failed', message: 'ledger unavailable', - details: { sessionId: sid('published'), workspaceId: 'ws' }, - }, - }, - } as never) + b.api.onCreate = () => Promise.resolve(err(new RemoteError( + 'session/workspace-attach-failed', + 'ledger unavailable', + { sessionId: sid('published'), workspaceId: 'ws' }, + ))) const failure = await b.svc.create({ workspaceId: 'ws' as never, sessionId: sid('published'), @@ -655,7 +647,7 @@ describe('create', () => { expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ requestedSessionId: 'published', - rpcError: { code: 'workspace-attach-failed' }, + rpcError: { code: 'session/workspace-attach-failed' }, }) expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true }) }) @@ -723,12 +715,10 @@ describe('fork', () => { }) await feedList(b, [{ id: 'source' }]) b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) - b.api.onRename = () => Promise.resolve(err({ - code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, - } as never)) + b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') }))) await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) - .rejects.toThrow('fork child rename failed: title-invalid: rejected') + .rejects.toThrow('fork child rename failed: session/title-invalid: rejected') expect(b.svc.binding(sid('child'))).toBeDefined() }) }) @@ -785,10 +775,7 @@ describe('blank mirror', () => { const b = bench() await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) const session = b.svc.binding(sid('s1'))!.session - b.api.onPrompt = () => Promise.resolve({ - rpcId: 'busy' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } }, - } as never) + b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {}))) const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue') expect(result.ok).toBe(false) // No flip on failure: local stays aligned with the host authority diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index e2e6292bce..3d7d549a26 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -14,7 +14,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import { vi } from 'vitest' import { - TypertRemoteFailure, + RemoteError, + remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' import SessionController from '../src/index.ts' @@ -224,14 +225,13 @@ function remoteResult( .catch((error: unknown) => ({ ok: false as const, error: signal?.aborted === true - ? { code: 'cancelled', message: 'request was aborted', details: {} } - : error instanceof TypertRemoteFailure - ? error.failure - : { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, + ? new RemoteError('gateway/cancelled', 'request was aborted', {}) + : remoteErrorOf(error) + ?? new RemoteError( + 'gateway/internal', + error instanceof Error ? error.message : String(error), + {}, + ), })) } diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 36afa47632..6e00845e35 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -2,17 +2,17 @@ import { describe, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, - RemoteStreamError, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { createSessionControlStream, SessionEventStream, - sessionStreamFailure, type SessionJournalChange, type SessionRemote, } from '../src/client/index.ts' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import type { SessionAddress, SessionControlFrame, @@ -73,12 +73,18 @@ function snapshot( } } -function sessionClient(remote: SessionTransportRemote) { +function sessionClient(remote: SessionTransportRemote): SessionRemotes { return { session: remote as SessionRemote, $stream: (options: RemoteStreamOptions) => ( new RemoteStream(AVAILABLE_CONNECTION, options) ), + commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) }, + subagents: { + list: () => Promise.reject(new Error('stream tests never read the subagent catalog')), + prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')), + interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')), + }, } } @@ -295,7 +301,7 @@ describe('Session Client stream adapters', () => { }) it('turns a pagination failure into a typed stream failure', async () => { - const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const + const failure = new RemoteError('session/not-found', 'missing', { sessionId: 'session-1' as never }) const remote = new ScriptedSessionRemote( [{ frames: [snapshot(-1, [])], hold: true }], [{ ok: false, error: failure }], @@ -306,11 +312,8 @@ describe('Session Client stream adapters', () => { }) await stream.open({}) - await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError) + await expect(stream.prepend({})).rejects.toMatchObject({ code: 'session/not-found' }) await expect(stream.open({})).rejects.toThrow('already opened') - expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details))) - .toEqual(failure) - expect(sessionStreamFailure(new Error('local'))).toBeUndefined() expect(remote.signals[0]?.aborted).toBe(false) expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }]) await stream.dispose() diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 8461d99a24..e04b430c71 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -296,7 +296,7 @@ describe('SessionHistoryController', () => { id: session.id, events: [event('fixture/start', 0), skipped, gap], } as unknown as Session, gap) - await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(followed.next()).rejects.toMatchObject({ code: 'gateway/internal' }) }) it('opens an empty source at cursor -1', async () => { @@ -396,15 +396,15 @@ describe('SessionHistoryController', () => { mode: 'continuable', }, throughSeq: 0, - }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' }, throughSeq: 0, - }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'session', sessionId: childSessionId }, throughSeq: 0, - }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + }, signal)).rejects.toMatchObject({ code: 'session/agent-busy' }) }) it('preserves a cold inspection failure for the Gateway error branch', async () => { @@ -438,10 +438,10 @@ describe('SessionHistoryController', () => { { address, throughSeq: -1, maxMessages: 0 }, { address, throughSeq: -1, maxMessages: 1.5 }, ]) { - await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) } await expect(transport.page({ address, throughSeq: 0 }, signal())) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) const corrupt = await setup() const corruptId = SessionId('missing-through-seq') @@ -455,7 +455,7 @@ describe('SessionHistoryController', () => { }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) for (const maxMessages of [0, 0.5]) { const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() - await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } }) @@ -463,7 +463,7 @@ describe('SessionHistoryController', () => { const { ctx, transport } = await setup() const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) const inspect = vi.fn(() => Promise.resolve(undefined)) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { @@ -471,7 +471,7 @@ describe('SessionHistoryController', () => { inspect, }) as never) await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) await expect(transport.page({ address: { kind: 'subagent', @@ -480,7 +480,7 @@ describe('SessionHistoryController', () => { mode: 'continuable', }, throughSeq: -1, - }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } }) + }, signal())).rejects.toMatchObject({ code: 'subagent/not-found' }) expect(inspect).toHaveBeenCalledTimes(2) }) @@ -494,7 +494,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.resolve({ meta: firstHeader, events: [] }), }) as never) await expect(first.transport.page({ address, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) const second = await setup() const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } @@ -504,7 +504,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.resolve({ meta: inspected, events: [] }), }) as never) await expect(second.transport.page({ address, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) }) it('serves cold ordinary history and validates every durable subagent descriptor state', async () => { @@ -538,18 +538,18 @@ describe('SessionHistoryController', () => { const missing = await setup() cold(missing.ctx, childHeader, []) await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const corrupt = await setup() cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })]) await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const ordinaryChild = await setup() const { origin: _origin, ...ordinaryChildHeader } = childHeader cold(ordinaryChild.ctx, ordinaryChildHeader, []) await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + .rejects.toMatchObject({ code: 'subagent/unauthorized' }) }) it('reports an unavailable descriptor when an observed child has no projection value', async () => { @@ -578,7 +578,7 @@ describe('SessionHistoryController', () => { address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, throughSeq: -1, }, signal())).rejects.toMatchObject({ - failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } }, + code: 'subagent/catalog-diagnostic', details: { reason: 'unsupported' }, }) await ctx.fiber.dispose() }) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index bea21672b7..aaa1503581 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -42,6 +42,7 @@ { "path": "../../session-query/session-query" }, { "path": "../../skill/skill" }, { "path": "../../subagent/subagent" }, + { "path": "../../util/time" }, { "path": "../../typert/protocol" }, { "path": "../../typert/registry" }, { "path": "../../workspace/workspace" } diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index a9db113e35..99c42a4cf0 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' /** @@ -30,11 +30,7 @@ const unsetRequestSchema = z.object({ ref: credentialRefSchema }) function parseRequest(method: string, schema: z.ZodType, value: unknown): T { const parsed = schema.safeParse(value) if (!parsed.success) { - throw new TypertRemoteFailure({ - code: 'bad-request', - message: `invalid payload for ${method}`, - details: { issues: parsed.error.issues }, - }) + throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues }) } return parsed.data } @@ -78,9 +74,10 @@ export class CredentialsController extends TypertRemoteService { * Describe several references for one configuration surface. Batched because * a settings page describes every reference its rows name at once, and one * round trip keeps those rows from settling separately. - * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar + * rejects the whole call as `gateway/bad-request`. * @returns one view per requested name, keyed by that name. - * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + * @throws RemoteError when the request is invalid or no credential provider is mounted. */ @Remote async describe(refs: string[]): Promise> { @@ -97,7 +94,7 @@ export class CredentialsController extends TypertRemoteService { * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async set(ref: string, value: string): Promise { @@ -110,7 +107,7 @@ export class CredentialsController extends TypertRemoteService { /** * Remove one reference from a configuration surface. * @param ref - reference name to remove. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async unset(ref: string): Promise { @@ -124,17 +121,17 @@ export class CredentialsController extends TypertRemoteService { private provider(): CredentialProvider { const credentials = this.ctx.get('credentials') if (credentials === undefined) { - throw new TypertRemoteFailure({ - code: 'internal', - message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', - details: {}, - }) + throw new RemoteError( + 'gateway/internal', + 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', + {}, + ) } return credentials } /** - * Run one remote write and report every refusal as `credential-rejected` + * Run one remote write and report every refusal as `credential/rejected` * carrying the seam's own message: a read-only source shadowing the reference * is what a configuration surface must show verbatim. Callers brand the * reference before entering, so a name outside the grammar never reaches this @@ -145,11 +142,12 @@ export class CredentialsController extends TypertRemoteService { try { await write() } catch (error: unknown) { - throw new TypertRemoteFailure({ - code: 'credential-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ref }, - }) + throw new RemoteError( + 'credential/rejected', + error instanceof Error ? error.message : String(error), + { ref }, + { cause: error }, + ) } } } diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index 5fa81d1518..19056ef1dd 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -10,12 +10,8 @@ import { dirname } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' -import { - InvalidPresetIdError, - PresetExistsError, - PresetNotWritableError, - UnknownPresetError, -} from '@deepseek-ai/dsh-agent-presets' +// Type-only: resolves the `agentPresets` Context augmentation this controller reads. +import type {} from '@deepseek-ai/dsh-agent-presets' import { canOpenNativePath, openNativePath, @@ -27,7 +23,7 @@ import type { SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-settings/types' import type { JsonValue } from '@deepseek-ai/dsh-session/types' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { CredentialsController } from './credentials.ts' import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts' @@ -88,7 +84,7 @@ declare module '@deepseek-ai/cordis' { * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot * ride a response. Writes expose the settings service's merge, replacement, * and path-addressed operations, and classify every provider refusal as - * `settings-conflict` or `settings-rejected` with the service's message. + * `settings/conflict` or `settings/rejected` with the service's message. */ export class SettingsController extends TypertRemoteService { static Config: Schema = Schema.object({ nativeOpen: Schema.boolean() }) @@ -116,7 +112,7 @@ export class SettingsController extends TypertRemoteService { * Describe every registered namespace for a configuration page: redacted * layered values plus the serialized schema the page renders its form from. * @returns provider writability, local-document presence, and one view per namespace. - * @throws TypertRemoteFailure when no settings provider is mounted. + * @throws RemoteError when no settings provider is mounted. */ @Remote describe(): SettingsDescribeValue { @@ -143,7 +139,7 @@ export class SettingsController extends TypertRemoteService { * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote update( @@ -160,7 +156,7 @@ export class SettingsController extends TypertRemoteService { * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote replace( @@ -179,7 +175,7 @@ export class SettingsController extends TypertRemoteService { * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( @@ -194,29 +190,29 @@ export class SettingsController extends TypertRemoteService { * Materialize the provider-owned settings document and open it in a native text editor. * @param signal - caller lifetime; abort terminates preparation or the native command. * @returns confirmation after the native opener accepts the document. - * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + * @throws RemoteError when no document exists, preparation fails, or opening fails. */ @Remote async openSettingsDocument(signal: AbortSignal): Promise { const settings = this.provider() - if (isAborted(signal)) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {}) let path: string | undefined try { path = await settings.prepareDocument() } catch (error: unknown) { - if (isAborted(signal)) throw cancelled('settings document preparation was aborted') - throw internal(`settings document preparation failed: ${messageOf(error)}`) + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document preparation was aborted', {}) + throw new RemoteError('gateway/internal', `settings document preparation failed: ${messageOf(error)}`, {}, { cause: error }) } if (path === undefined) { - throw internal('settings provider has no local document to open') + throw new RemoteError('gateway/internal', 'settings provider has no local document to open', {}) } - if (isAborted(signal)) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {}) try { await this.openTextFile(path, signal) return { opened: true } } catch (error: unknown) { - if (isAborted(signal)) throw cancelled('settings document open was aborted') - throw internal(`path open failed: ${messageOf(error)}`) + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {}) + throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error }) } } @@ -225,7 +221,7 @@ export class SettingsController extends TypertRemoteService { * @param agentPreset - preset id resolved against Host-owned roots. * @param signal - caller lifetime; abort terminates the native command. * @returns an opened confirmation or the resolved directory for text display. - * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. */ @Remote async openAgentPresetDirectory( @@ -233,35 +229,32 @@ export class SettingsController extends TypertRemoteService { signal: AbortSignal, ): Promise { if (agentPreset.length === 0) { - throw new TypertRemoteFailure({ - code: 'bad-request', message: 'agent preset id must not be empty', details: {}, - }) + throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {}) } const presets = this.ctx.get('agentPresets') if (presets === undefined) { - throw new TypertRemoteFailure({ - code: 'agent-preset-not-found', - message: 'this deployment composes no agent presets', - details: { agentPreset, available: [] }, - }) + throw new RemoteError( + 'agent-preset/not-found', + 'this deployment composes no agent presets', + { agentPreset, available: [] }, + ) } - let directory: string - try { - const preset = await presets.resolve(agentPreset) - if (preset.trust !== 'user') { - throw new PresetNotWritableError(preset.id, 'it ships with the deployment') - } - directory = dirname(preset.path) - } catch (error: unknown) { - throw presetFailure(agentPreset, error) + const preset = await presets.resolve(agentPreset) + if (preset.trust !== 'user') { + throw new RemoteError( + 'agent-preset/read-only', + `agent-presets: preset "${preset.id}" cannot be written: it ships with the deployment`, + { agentPreset: preset.id, reason: 'it ships with the deployment' }, + ) } + const directory = dirname(preset.path) if (!this.canOpenPath()) return { opened: false, path: directory } try { await this.openPath(directory, signal) return { opened: true } } catch (error: unknown) { - if (signal.aborted) throw cancelled('path open was aborted') - throw internal(`path open failed: ${messageOf(error)}`) + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) + throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error }) } } @@ -273,11 +266,7 @@ export class SettingsController extends TypertRemoteService { ): Promise { const parsed = settingsNamespaceRequestSchema.safeParse({ ns }) if (!parsed.success) { - throw new TypertRemoteFailure({ - code: 'bad-request', - message: `invalid payload for settings.${mode}`, - details: { issues: parsed.error.issues }, - }) + throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues }) } const settings = this.provider() let branded @@ -286,7 +275,7 @@ export class SettingsController extends TypertRemoteService { // unregistered one does. branded = settingsNamespace(parsed.data.ns) } catch (error: unknown) { - throw rejected(ns, error) + throw new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error }) } try { if (mode === 'update') await settings.update(branded, input, expectedRevision) @@ -299,11 +288,7 @@ export class SettingsController extends TypertRemoteService { if (descriptor === undefined) { // The write committed but the namespace vanished before this read: only a // concurrent registrant disposal can produce it. - throw new TypertRemoteFailure({ - code: 'internal', - message: `settings namespace "${ns}" was disposed after the ${mode}`, - details: {}, - }) + throw new RemoteError('gateway/internal', `settings namespace "${ns}" was disposed after the ${mode}`, {}) } return namespaceView(descriptor) } @@ -312,11 +297,11 @@ export class SettingsController extends TypertRemoteService { private provider(): SettingsProvider { const settings = this.ctx.get('settings') if (settings === undefined) { - throw new TypertRemoteFailure({ - code: 'internal', - message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', - details: {}, - }) + throw new RemoteError( + 'gateway/internal', + 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', + {}, + ) } return settings } @@ -326,40 +311,6 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function internal(message: string): TypertRemoteFailure { - return new TypertRemoteFailure({ code: 'internal', message, details: {} }) -} - -function cancelled(message: string): TypertRemoteFailure { - return new TypertRemoteFailure({ code: 'cancelled', message, details: {} }) -} - -function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure { - if (error instanceof UnknownPresetError) { - return new TypertRemoteFailure({ - code: 'agent-preset-not-found', - message: error.message, - details: { agentPreset: error.presetId, available: [...error.available] }, - }) - } - if (error instanceof PresetNotWritableError) { - return new TypertRemoteFailure({ - code: 'agent-preset-read-only', - message: error.message, - details: { agentPreset, reason: error.message }, - }) - } - if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { - return new TypertRemoteFailure({ - code: 'agent-preset-invalid', - message: error.message, - details: { agentPreset, reason: error.message }, - }) - } - if (error instanceof TypertRemoteFailure) return error - return internal(`agent preset "${agentPreset}": ${String(error)}`) -} - /** * Classify one seam refusal. A stale writer is its own outcome, not a malformed * request: the client must re-read and re-apply rather than treat the write as @@ -368,19 +319,16 @@ function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure * @param error - whatever the seam threw. * @returns the failure to raise for that refusal. */ -function rejected(ns: string, error: unknown): TypertRemoteFailure { +function rejected(ns: string, error: unknown): RemoteError { if (error instanceof SettingsConflictError) { - return new TypertRemoteFailure({ - code: 'settings-conflict', - message: error.message, - details: { ns, expected: error.expected, actual: error.actual }, - }) + return new RemoteError( + 'settings/conflict', + error.message, + { ns, expected: error.expected, actual: error.actual }, + { cause: error }, + ) } - return new TypertRemoteFailure({ - code: 'settings-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ns }, - }) + return new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error }) } export default SettingsController diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 5fde28ae89..cc9806aa84 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -7,28 +7,26 @@ * @module @deepseek-ai/dsh-api-settings-controller/types */ -/** Stable settings failure details returned by the `settings` namespace. */ -export interface SettingsErrorDetailsMap { - /** - * Every seam refusal that is not a stale write: an unregistered or malformed - * namespace, a read-only provider, schema validation, storage. - */ - 'settings-rejected': { readonly ns: string } - /** - * The stored revision moved after the caller read it. Its own outcome rather - * than an invalid request: the caller must re-read and re-apply. - */ - 'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number } -} - -/** Settings business failure carried by a rejected Remote call. */ -export type SettingsError = { - [Code in keyof SettingsErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: SettingsErrorDetailsMap[Code] +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** + * Every seam refusal that is not a stale write: an unregistered or malformed + * namespace, a read-only provider, schema validation, storage. + */ + 'settings/rejected': { readonly ns: string } + /** + * The stored revision moved after the caller read it. Its own outcome rather + * than an invalid request: the caller must re-read and re-apply. + */ + 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } + /** + * The provider refused a valid credential write, for example because a + * read-only source shadows the reference. The details name only the + * reference, never the value. + */ + 'credential/rejected': { readonly ref: string } } -}[keyof SettingsErrorDetailsMap] +} /** Confirmation that the settings document was handed to the native editor. */ export interface SettingsDocumentOpenValue { @@ -39,21 +37,3 @@ export interface SettingsDocumentOpenValue { export type AgentPresetDirectoryOpenValue = | { readonly opened: true } | { readonly opened: false; readonly path: string } - -/** Stable credential failure details returned by the `credentials` namespace. */ -export interface CredentialErrorDetailsMap { - /** - * The provider refused a valid write, for example because a read-only source - * shadows the reference. The details name only the reference, never the value. - */ - 'credential-rejected': { readonly ref: string } -} - -/** Credential business failure carried by a rejected Remote call. */ -export type CredentialError = { - [Code in keyof CredentialErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: CredentialErrorDetailsMap[Code] - } -}[keyof CredentialErrorDetailsMap] diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index a27af7687c..9ac14d9eb8 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import CredentialsController from '../src/credentials.ts' import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' @@ -60,9 +60,8 @@ describe('the credentials Remote namespace a configuration surface calls', () => () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), ]) { const failure = await call().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toEqual({ - code: 'internal', + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'gateway/internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {}, }) @@ -87,8 +86,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => () => controller.unset('not a var'), ]) { const failure = await call().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) } }) @@ -97,7 +95,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`) expect(Object.keys(await controller.describe(accepted))).toHaveLength(64) const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) }) it('answers only the fields the view declares, whatever a provider returns', async () => { @@ -117,12 +115,11 @@ describe('the credentials Remote namespace a configuration surface calls', () => .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) }) - it('reports a refused write as credential-rejected naming only the reference', async () => { + it('reports a refused write as credential/rejected naming only the reference', async () => { const controller = await boot({}, RejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - const { code, message, details } = (failure as TypertRemoteFailure).failure - expect(code).toBe('credential-rejected') + const { code, message, details } = remoteErrorOf(failure) ?? {} + expect(code).toBe('credential/rejected') expect(message).toContain('read-only source') expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) }) @@ -130,12 +127,12 @@ describe('the credentials Remote namespace a configuration surface calls', () => it('reports an empty value as bad-request', async () => { const controller = await boot() const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) }) it('stringifies a refusal that is not an Error', async () => { const controller = await boot({}, LiteralRejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused') + expect(remoteErrorOf(failure)?.message).toBe('the store refused') }) }) diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index 7195e6650f..d9b0481cdd 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -1,14 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { - InvalidPresetIdError, - PresetExistsError, - UnknownPresetError, -} from '@deepseek-ai/dsh-agent-presets' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import SettingsController from '../src/index.ts' import { MemorySettings } from '../../../settings/settings/tests/memory.ts' @@ -103,9 +98,8 @@ describe('the settings Remote namespace a configuration page calls', () => { ] for (const call of calls) { const failure = await Promise.resolve().then(call).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toEqual({ - code: 'internal', + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'gateway/internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {}, }) @@ -186,16 +180,15 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }]) }) - it('refuses a stale write as settings-conflict carrying both revisions', async () => { + it('refuses a stale write as settings/conflict carrying both revisions', async () => { const { controller } = await boot() const held = controller.describe().namespaces[0]!.revision await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held) const failure = await controller .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held) .catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - const { code, details } = (failure as TypertRemoteFailure).failure - expect(code).toBe('settings-conflict') + const { code, details } = remoteErrorOf(failure) ?? {} + expect(code).toBe('settings/conflict') expect(details).toMatchObject({ ns: 'ui-test', expected: held }) }) @@ -204,8 +197,8 @@ describe('the settings Remote namespace a configuration page calls', () => { for (const ns of ['Not A Namespace', 'unregistered']) { const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined) .catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ - code: 'settings-rejected', + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'settings/rejected', details: { ns }, }) } @@ -219,17 +212,16 @@ describe('the settings Remote namespace a configuration page calls', () => { () => controller.mutate('', [], undefined), ]) { const failure = await call().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) } }) - it('reports a refused write as settings-rejected carrying the seam message', async () => { + it('reports a refused write as settings/rejected carrying the seam message', async () => { const { controller } = await boot(RefusingSettings) const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) .catch((error: unknown) => error) - const { code, message } = (failure as TypertRemoteFailure).failure - expect(code).toBe('settings-rejected') + const { code, message } = remoteErrorOf(failure) ?? {} + expect(code).toBe('settings/rejected') expect(message).toContain('read-only in this deployment') }) @@ -237,15 +229,15 @@ describe('the settings Remote namespace a configuration page calls', () => { const { controller } = await boot(LiteralRefusingSettings) const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) .catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked') + expect(remoteErrorOf(failure)?.message).toBe('the document is locked') }) it('reports a namespace disposed between the write and its read-back', async () => { const { controller } = await boot(VanishingSettings) const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined) .catch((error: unknown) => error) - const { code, message } = (failure as TypertRemoteFailure).failure - expect(code).toBe('internal') + const { code, message } = remoteErrorOf(failure) ?? {} + expect(code).toBe('gateway/internal') expect(message).toContain('was disposed after the mutate') }) @@ -265,13 +257,13 @@ describe('the settings Remote namespace a configuration page calls', () => { it('preserves settings-document absence, failure, and cancellation', async () => { const absent = await boot() const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal) - await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(missingDocument).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(missingDocument).rejects.toThrow('no local document') const failed = await boot(DocumentSettings) vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed')) const failedRead = failed.controller.openSettingsDocument(new AbortController().signal) - await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failedRead).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(failedRead).rejects.toThrow('read failed') const cancelled = new AbortController() @@ -279,7 +271,7 @@ describe('the settings Remote namespace a configuration page calls', () => { const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument') prepare.mockClear() await expect(failed.controller.openSettingsDocument(cancelled.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) expect(prepare).not.toHaveBeenCalled() }) @@ -296,7 +288,7 @@ describe('the settings Remote namespace a configuration page calls', () => { abort.abort(new Error('cancelled')) prepared.resolve('/tmp/settings.yaml') - await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } }) + await expect(opening).rejects.toMatchObject({ code: 'gateway/cancelled' }) expect(openTextFile).not.toHaveBeenCalled() }) @@ -309,9 +301,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }) await expect(controller.openSettingsDocument(new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: 'path open failed: no default editor' }, - }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: no default editor' }) }) it('classifies cancellation while preparing or opening the settings document', async () => { @@ -324,7 +314,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }) const preparingController = new SettingsController(preparing) await expect(preparingController.openSettingsDocument(prepareAbort.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) const opening = new Context() await opening.plugin(DocumentSettings) @@ -337,7 +327,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }, }) await expect(openingController.openSettingsDocument(openAbort.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) it('opens a user Agent preset directory or returns its path without a native opener', async () => { @@ -391,11 +381,11 @@ describe('the settings Remote namespace a configuration page calls', () => { } as never) const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } }) + .rejects.toMatchObject({ code: 'agent-preset/read-only' }) const missing = new SettingsController(new Context()) await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } }) + .rejects.toMatchObject({ code: 'agent-preset/not-found' }) }) it('rejects an empty Agent preset id before resolving a provider', async () => { @@ -405,23 +395,20 @@ describe('the settings Remote namespace a configuration page calls', () => { const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) expect(resolve).not.toHaveBeenCalled() }) - it.each([ - [new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'], - [new InvalidPresetIdError('../bad'), 'agent-preset-invalid'], - [new PresetExistsError('taken'), 'agent-preset-invalid'], - [new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'], - ['unexpected preset failure', 'internal'], - ] as const)('maps Agent preset resolution failure %#', async (error, code) => { + it('raises an Agent preset resolution failure as the roster reported it', async () => { const ctx = new Context() - ctx.provide('agentPresets', { resolve: async () => { throw error } } as never) + const reported = new RemoteError('agent-preset/not-found', 'no such preset', { + agentPreset: 'mine', available: ['standard'], + }) + ctx.provide('agentPresets', { resolve: async () => { throw reported } } as never) const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code } }) + .rejects.toBe(reported) }) it('classifies cancellation and non-Error failures from the preset opener', async () => { @@ -441,10 +428,8 @@ describe('the settings Remote namespace a configuration page calls', () => { const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath }) await expect(controller.openAgentPresetDirectory('first', abort.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) await expect(controller.openAgentPresetDirectory('second', new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, - }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' }) }) }) diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 0e2c3c5fa7..fffa05c18f 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -7,7 +7,7 @@ import { type ClientRemote, } from '@deepseek-ai/dsh-api-gateway/client' import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts' -import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts' +import type { WorkspaceFollowSink } from './model.ts' import { ClientWorkspaceModel } from './model.ts' import { WorkspaceController } from './service.ts' @@ -19,10 +19,6 @@ export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' export type { WorkspaceId, WorkspaceView } from '../types.ts' -type WorkspaceStreamRemote = Pick & { - readonly workspace: WorkspaceRemote -} - type WorkspaceBaselineFrame = Extract /** Gateway-owned snapshot stream configured for Workspace state. */ @@ -46,10 +42,9 @@ export const inject = ['remote', 'remote.workspace'] * @param ctx - Client root Context. */ export function apply(ctx: Context): void { - const remote = ctx.remote as WorkspaceStreamRemote - const model = new ClientWorkspaceModel(remote.workspace) + const model = new ClientWorkspaceModel(ctx.remote.workspace) new WorkspaceController(ctx, model) - const control = createWorkspaceStateStream(remote, { + const control = createWorkspaceStateStream(ctx.remote, { accept: model, carrierFailed: () => { model.handleCarrierFailure() }, failed: (error) => { model.handleStreamFailure(error) }, @@ -73,12 +68,12 @@ export interface WorkspaceStateStreamOptions { /** * Create the reconnecting Workspace state stream. - * @param remote - generated Workspace namespace and Gateway stream factory. + * @param remote - Client Remote face carrying the Workspace namespace and the stream factory. * @param options - Workspace state destinations. * @returns an unstarted stream owned by the Client Workspace runtime. */ export function createWorkspaceStateStream( - remote: WorkspaceStreamRemote, + remote: ClientRemote, options: WorkspaceStateStreamOptions, ): WorkspaceStateStream { const stream = remote.$stream({ diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 2ec365e8b2..326ff7031a 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -2,6 +2,7 @@ import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' import type { WorkspaceArchiveSessionRequest, @@ -82,12 +83,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { * @returns generated Remote result. */ async create(input: WorkspaceCreateRequest): Promise> { - let result: RemoteResult - try { - result = await this.remote.create(input) - } catch (error) { - result = failureResult(error) - } + const result = await this.remote.create(input) if (result.ok) this.upsert(result.value.workspace) return result } @@ -129,19 +125,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { const frameGeneration = this.orderFrameGeneration const localOrder = this.items.map(workspace => workspace.workspaceId) this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)) - let result: RemoteResult - try { - result = await this.remote.insertBefore({ - workspaceId, - ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, - }) - } catch (error) { - if (requestGeneration === this.orderRequestGeneration - && frameGeneration === this.orderFrameGeneration) { - this.installOrder(this.committedOrder) - } - throw error - } + const result = await this.remote.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + }) if (requestGeneration === this.orderRequestGeneration && frameGeneration === this.orderFrameGeneration) { this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok) @@ -233,8 +220,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { * @param error - terminal stream failure. */ handleStreamFailure(error: unknown): void { + if (!isRemoteFailure(error)) throw error this.state = 'error' - this.error = failureOf(error) + this.error = error this.invalidate() } @@ -369,15 +357,3 @@ function insertIdBefore( const at = beforeId === undefined ? without.length : without.indexOf(beforeId) return [...without.slice(0, at), id, ...without.slice(at)] } - -function failureResult(error: unknown): RemoteResult { - return { ok: false, error: failureOf(error) } -} - -function failureOf(error: unknown): RemoteFailure { - return { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - } -} diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index a8511ac61e..3cfd42ce0a 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -11,7 +11,7 @@ import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' export class WorkspaceCreateError extends Error { override readonly name = 'WorkspaceCreateError' - /** @param rpcError - Host business or folded transport failure. */ + /** @param rpcError - Host business or folded carrier failure. */ constructor(readonly rpcError: RemoteFailure) { super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) } diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts index 0bb36b0897..af48cb82ad 100644 --- a/packages/api/workspace-controller/src/commands.ts +++ b/packages/api/workspace-controller/src/commands.ts @@ -8,7 +8,7 @@ import { WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { workspaceView } from './feed.ts' import type { WorkspaceArchiveSessionRequest, @@ -46,11 +46,12 @@ export class WorkspaceCommands { const workspace = await this.ctx.workspaceRegistry.create(request.path) return { workspace: workspaceView(workspace), created: true } } catch (error) { - if (error instanceof TypertRemoteFailure) throw error - throw failure( - 'workspace-invalid-path', + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError( + 'workspace/invalid-path', `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`, { path: request.path }, + { cause: error }, ) } }) @@ -64,19 +65,15 @@ export class WorkspaceCommands { rename(request: WorkspaceRenameRequest): Promise { const title = request.title.trim() if (title === '') { - return Promise.reject(failure( - 'bad-request', - 'Workspace rename requires a non-blank title', - {}, - )) + return Promise.reject(new RemoteError('gateway/bad-request', 'Workspace rename requires a non-blank title', {})) } return this.enqueue(async () => { const workspace = this.requireWorkspace(request.workspaceId) if (title !== workspace.title) { if (this.ctx.workspaceRegistry.list().some(candidate => candidate.id !== workspace.id && candidate.title === title)) { - throw failure( - 'workspace-name-conflict', + throw new RemoteError( + 'workspace/name-conflict', `Workspace name '${title}' is already in use`, { name: title }, ) @@ -132,8 +129,8 @@ export class WorkspaceCommands { await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId) } catch (error) { if (!(error instanceof WorkspaceMoveInvalidError)) throw error - throw failure( - 'workspace-move-invalid', + throw new RemoteError( + 'workspace/move-invalid', error.message, { workspaceId: request.workspaceId, @@ -142,6 +139,7 @@ export class WorkspaceCommands { ? {} : { beforeSessionId: request.beforeSessionId }, }, + { cause: error }, ) } return { workspace: workspaceView(workspace) } @@ -157,7 +155,7 @@ export class WorkspaceCommands { await this.ctx.workspaceRegistry.archiveSession(request.sessionId) } catch (error) { if (!(error instanceof WorkspaceUnknownSessionError)) throw error - throw failure('session-not-found', error.message, { sessionId: request.sessionId }) + throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error }) } return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] } } @@ -175,22 +173,14 @@ export class WorkspaceCommands { } } -function workspaceNotFound(workspaceId: WorkspaceId): TypertRemoteFailure { - return failure( - 'workspace-not-found', +function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<'workspace/not-found'> { + return new RemoteError( + 'workspace/not-found', `Workspace "${workspaceId}" not found`, { workspaceId }, ) } -function failure( - code: string, - message: string, - details: object, -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/packages/api/workspace-controller/src/directory-picker.ts b/packages/api/workspace-controller/src/directory-picker.ts index ee58ef7b83..41f7db97f4 100644 --- a/packages/api/workspace-controller/src/directory-picker.ts +++ b/packages/api/workspace-controller/src/directory-picker.ts @@ -6,12 +6,14 @@ import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker' +import type { + DirectoryPickerCapabilities, DirectoryPickerErrorCode, +} from '@deepseek-ai/dsh-host-directory-picker' // The seam owns the listing declaration; the generator requires the reference // site to name that package rather than this package's re-export of it. import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import type { DirectoryPickerErrorDetailsMap } from './types.ts' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteErrorCode } from '@deepseek-ai/dsh-typert-protocol' const createDirectoryRequestSchema = z.object({ path: z.string(), @@ -86,8 +88,8 @@ export class DirectoryPickerController extends TypertRemoteService { async createDirectory(path: string, name: string): Promise { const request = createDirectoryRequestSchema.safeParse({ path, name }) if (!request.success) { - throw pickerFailureOf( - 'bad-request', + throw new RemoteError( + 'gateway/bad-request', 'invalid payload for host.createDirectory', { issues: request.error.issues }, ) @@ -107,8 +109,8 @@ export class DirectoryPickerController extends TypertRemoteService { ): DirectoryPickerCapabilities[Kind] { const capability = this.ctx.directoryPicker.capability() if (capability.kind !== kind) { - throw pickerFailureOf( - 'directory-picker-unavailable', + throw new RemoteError( + 'directory-picker/unavailable', `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, { capability: capability.kind }, ) @@ -118,19 +120,15 @@ export class DirectoryPickerController extends TypertRemoteService { } /** - * Raise one entry of the picking wire failure vocabulary. - * @param code - the failure code a caller discriminates on. - * @param message - operator-facing description. - * @param details - the payload this code carries. - * @returns the failure to throw across the Remote boundary. + * Wire code answered for each seam browse failure. The seam's closed codes are + * its own local vocabulary, so this controller owns the projection onto the + * `directory-picker/*` codes a Remote caller discriminates on. */ -function pickerFailureOf( - code: Code, - message: string, - details: DirectoryPickerErrorDetailsMap[Code], -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} +const BROWSE_FAILURE_CODES = { + 'directory-unreadable': 'directory-picker/unreadable', + 'directory-exists': 'directory-picker/exists', + 'directory-create-failed': 'directory-picker/create-failed', +} as const satisfies Record /** * Classify a browse-primitive rejection: the seam's own closed codes carry the @@ -138,16 +136,21 @@ function pickerFailureOf( * @param error - the primitive's rejection. * @returns the failure to throw across the Remote boundary. */ -function browseFailure(error: unknown): TypertRemoteFailure { +function browseFailure(error: unknown): RemoteError { if (error instanceof DirectoryPickerError) { - return pickerFailureOf(error.code, error.message, { path: error.path }) + return new RemoteError( + BROWSE_FAILURE_CODES[error.code], + error.message, + { path: error.path }, + { cause: error }, + ) } - return pickerFailureOf('internal', errorMessage(error), {}) + return new RemoteError('gateway/internal', errorMessage(error), {}, { cause: error }) } /** * Classify a cancellable primitive's rejection. An abort is the caller's own - * timeout or disconnect, not a backend failure, so it answers `cancelled` + * timeout or disconnect, not a backend failure, so it answers `gateway/cancelled` * before the business classification runs. * @param error - the primitive's rejection. * @param signal - the caller lifetime the primitive ran under. @@ -160,10 +163,10 @@ function cancellableFailure( signal: AbortSignal, cancelled: string, failed?: string, -): TypertRemoteFailure { - if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {}) +): RemoteError { + if (signal.aborted) return new RemoteError('gateway/cancelled', cancelled, {}, { cause: error }) if (failed === undefined) return browseFailure(error) - return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {}) + return new RemoteError('gateway/internal', `${failed}: ${errorMessage(error)}`, {}, { cause: error }) } function errorMessage(error: unknown): string { diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index 552a9e20c3..aa053c8d67 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -7,9 +7,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { z as zCore } from 'zod' - -type ZodIssue = zCore.core.$ZodIssue export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' @@ -29,45 +26,27 @@ export interface WorkspaceView { readonly updatedAt: string } -/** Stable Workspace failure details returned by unary methods. */ -export interface WorkspaceErrorDetailsMap { - 'bad-request': Record - 'workspace-invalid-path': { readonly path: string } - 'workspace-not-found': { readonly workspaceId: WorkspaceId } - 'workspace-name-conflict': { readonly name: string } - 'workspace-move-invalid': { - readonly workspaceId: WorkspaceId - readonly sessionId: SessionId - readonly beforeSessionId?: SessionId +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The requested directory cannot back a Workspace. */ + 'workspace/invalid-path': { readonly path: string } + /** Another Workspace already uses the requested name. */ + 'workspace/name-conflict': { readonly name: string } + /** The Session or its anchor is not in the Workspace's manual order. */ + 'workspace/move-invalid': { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + /** The verb needs an interaction the composed backend does not serve. */ + 'directory-picker/unavailable': { readonly capability: string } + /** The target is not fully qualified, or the backend cannot list it. */ + 'directory-picker/unreadable': { readonly path: string } + /** A child of that name is already there. */ + 'directory-picker/exists': { readonly path: string } + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + 'directory-picker/create-failed': { readonly path: string } } - 'session-not-found': { readonly sessionId: SessionId } -} - -/** Workspace business failure returned without throwing a carrier error. */ -export type WorkspaceError = { - [Code in keyof WorkspaceErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: WorkspaceErrorDetailsMap[Code] - } -}[keyof WorkspaceErrorDetailsMap] - -/** Stable directory-picking failure details returned by the picking wire verbs. */ -export interface DirectoryPickerErrorDetailsMap { - /** The directory creation request violates its semantic input constraints. */ - 'bad-request': { readonly issues: ZodIssue[] } - /** The verb needs an interaction the composed backend does not serve. */ - 'directory-picker-unavailable': { readonly capability: string } - /** The target is not fully qualified, or the backend cannot list it. */ - 'directory-unreadable': { readonly path: string } - /** A child of that name is already there. */ - 'directory-exists': { readonly path: string } - /** The parent is not fully qualified, the name is not one segment, or creation failed. */ - 'directory-create-failed': { readonly path: string } - /** The caller's own timeout or disconnect ended the chooser or the scan. */ - cancelled: Record - /** A backend failure with no seam code of its own. */ - internal: Record } /** Existing directory requested for Workspace adoption. */ diff --git a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts index 32fd2dd6a1..307de96dc4 100644 --- a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts +++ b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { DirectoryPickerController } from '../src/directory-picker.ts' const roots: Context[] = [] @@ -60,8 +60,9 @@ async function refused(call: Promise): Promise<{ code: string; message: try { await call } catch (error: unknown) { - if (!(error instanceof TypertRemoteFailure)) throw error - return { ...error.failure } + const failure = remoteErrorOf(error) + if (failure === undefined) throw error + return { code: failure.code, message: failure.message, details: failure.details } } throw new Error('the call was expected to be refused') } @@ -85,18 +86,18 @@ describe('directoryPicker pick Remote', () => { const abort = new AbortController() const pending = refused(picker.pick(abort.signal)) abort.abort() - expect((await pending).code).toBe('cancelled') + expect((await pending).code).toBe('gateway/cancelled') const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) const failure = await refused(broken.pick(new AbortController().signal)) - expect(failure.code).toBe('internal') + expect(failure.code).toBe('gateway/internal') expect(failure.message).toContain('no chooser installed') }) it('refuses the native verb under a browse composition', async () => { const picker = await harness(BROWSE_STUB) const failure = await refused(picker.pick(new AbortController().signal)) - expect(failure.code).toBe('directory-picker-unavailable') + expect(failure.code).toBe('directory-picker/unavailable') expect(failure.message).toContain('needs the native capability') expect(failure.details).toEqual({ capability: 'browse' }) }) @@ -115,12 +116,12 @@ describe('directoryPicker browse Remotes', () => { it('maps the seam\'s typed failures and folds unknown throws to internal', async () => { const picker = await harness(BROWSE_STUB) expect(await refused(picker.list('/denied', new AbortController().signal))) - .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } }) - expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists') - expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal') + .toMatchObject({ code: 'directory-picker/unreadable', details: { path: '/denied' } }) + expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-picker/exists') + expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('gateway/internal') const thrown = await refused(picker.createDirectory('/home/user', 'gone')) - expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' }) + expect(thrown).toMatchObject({ code: 'gateway/internal', message: 'the volume vanished' }) }) it('rejects invalid child names before capability dispatch', async () => { @@ -134,7 +135,7 @@ describe('directoryPicker browse Remotes', () => { for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { const failure = await refused(picker.createDirectory('/home/user', name)) expect(failure).toMatchObject({ - code: 'bad-request', + code: 'gateway/bad-request', message: 'invalid payload for host.createDirectory', }) expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true) @@ -153,14 +154,14 @@ describe('directoryPicker browse Remotes', () => { const abort = new AbortController() const pending = refused(picker.list(undefined, abort.signal)) abort.abort() - expect((await pending).code).toBe('cancelled') + expect((await pending).code).toBe('gateway/cancelled') }) it('refuses the browse verbs under a native composition', async () => { const picker = await harness() expect(await refused(picker.list(undefined, new AbortController().signal))) - .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } }) expect(await refused(picker.createDirectory('/x', 'y'))) - .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } }) }) }) diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index 572f5b8f9c..3f097b4116 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -15,11 +15,10 @@ import type { WorkspaceOrderValue, WorkspaceRenameRequest, WorkspaceValue, - WorkspaceError, WorkspaceId, WorkspaceView, } from '../src/types.ts' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId @@ -44,7 +43,7 @@ function remoteOk(value: T): RemoteResult { return { ok: true, value } } -function workspaceError(error: WorkspaceError): RemoteResult { +function workspaceError(error: RemoteFailure): RemoteResult { return { ok: false, error } } @@ -158,17 +157,17 @@ describe('ClientWorkspaceModel', () => { model.handleCarrierFailure() expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible']) - model.handleStreamFailure(new Error('wire down')) + model.handleStreamFailure(new RemoteError('gateway/internal', 'wire down', {})) expect(model.getSnapshot()).toMatchObject({ - phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' }, + phase: 'ready', state: 'error', error: { code: 'gateway/internal', message: 'wire down' }, }) - model.handleStreamFailure('plain failure') - expect(model.getSnapshot().error?.message).toBe('plain failure') + // An unmarked value never crosses the stream boundary: it is a local fault. + expect(() => { model.handleStreamFailure('plain failure') }).toThrow() baseline(model, [workspace('restored')]) expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) }) - it('creates by path, prepends the returned row, and folds rejected calls', async () => { + it('creates by path and prepends the returned row', async () => { const remote = new FakeWorkspaceRemote() const model = modelFor(remote) remote.onCreate = request => Promise.resolve(remoteOk({ @@ -178,11 +177,6 @@ describe('ClientWorkspaceModel', () => { await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } }) expect(model.getSnapshot().items[0]?.workspaceId).toBe('created') - - remote.onCreate = () => Promise.reject(new Error('create transport')) - await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({ - ok: false, error: { code: 'internal', message: 'create transport' }, - }) }) it('lets newer stream order outrank unary echoes and rolls failures back', async () => { @@ -199,22 +193,16 @@ describe('ClientWorkspaceModel', () => { await pending expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - remote.onInsertBefore = () => Promise.resolve(workspaceError({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') }, - })) + remote.onInsertBefore = () => Promise.resolve(workspaceError( + new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('three') }), + )) const rejected = model.insertBefore(wid('three')) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) await expect(rejected).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - - remote.onInsertBefore = () => Promise.reject(new Error('transport down')) - const disconnected = model.insertBefore(wid('three'), wid('one')) - expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) - await expect(disconnected).rejects.toThrow('transport down') - expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) }) - it('keeps a newer optimistic reorder when an older transport call rejects', async () => { + it('keeps a newer optimistic reorder when an older refused call settles', async () => { const remote = new FakeWorkspaceRemote() const model = modelFor(remote) baseline(model, [workspace('one'), workspace('two'), workspace('three')]) @@ -225,8 +213,10 @@ describe('ClientWorkspaceModel', () => { const first = model.insertBefore(wid('three'), wid('one')) const second = model.insertBefore(wid('two'), wid('three')) - firstGate.reject(new Error('first transport failed')) - await expect(first).rejects.toThrow('first transport failed') + firstGate.resolve(workspaceError( + new RemoteError('workspace/not-found', 'first refused', { workspaceId: wid('three') }), + )) + await expect(first).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] })) await expect(second).resolves.toMatchObject({ ok: true }) @@ -244,14 +234,10 @@ describe('ClientWorkspaceModel', () => { const first = model.insertBefore(wid('three'), wid('one')) const second = model.insertBefore(wid('two'), wid('three')) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) - firstGate.resolve(workspaceError({ - code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') }, - })) + firstGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'first rejected', { workspaceId: wid('three') }))) await expect(first).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) - secondGate.resolve(workspaceError({ - code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') }, - })) + secondGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'second rejected', { workspaceId: wid('two') }))) await expect(second).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) }) @@ -284,15 +270,11 @@ describe('ClientWorkspaceModel', () => { const model = modelFor(remote) baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')]) - remote.onRename = () => Promise.resolve(workspaceError({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, - })) + remote.onRename = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') }))) await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items[0]?.title).toBe('one') - remote.onDelete = () => Promise.resolve(workspaceError({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, - })) + remote.onDelete = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') }))) await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items).toHaveLength(1) @@ -306,11 +288,9 @@ describe('ClientWorkspaceModel', () => { request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' }, }) - remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({ - code: 'workspace-move-invalid', - message: 'invalid move', - details: { workspaceId: wid('one'), sessionId: sid('second') }, - })) + remote.onInsertSessionBefore = () => Promise.resolve(workspaceError( + new RemoteError('workspace/move-invalid', 'invalid move', { workspaceId: wid('one'), sessionId: sid('second') }), + )) await expect(model.insertSessionBefore(wid('one'), sid('second'))) .resolves.toMatchObject({ ok: false }) expect(remote.calls).toContainEqual({ @@ -318,9 +298,9 @@ describe('ClientWorkspaceModel', () => { request: { workspaceId: 'one', sessionId: 'second' }, }) - remote.onArchiveSession = () => Promise.resolve(workspaceError({ - code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') }, - })) + remote.onArchiveSession = () => Promise.resolve(workspaceError( + new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }), + )) await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().archivedSessionIds).toEqual(['archived']) remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index bdd01d33ea..52d7a21020 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -3,11 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, + type ClientRemote, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import * as WorkspaceClientPlugin from '../src/client/index.ts' import { ClientWorkspaceModel, @@ -29,7 +30,6 @@ import type { WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspaceRenameRequest, - WorkspaceError, WorkspaceId, WorkspaceValue, WorkspaceView, @@ -45,11 +45,11 @@ const AVAILABLE_CONNECTION = { function workspaceClient( remote: WorkspaceRemote, connection: Pick = AVAILABLE_CONNECTION, -) { +): ClientRemote { return { workspace: remote, $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), - } + } as unknown as ClientRemote } interface Generation { @@ -94,7 +94,7 @@ function remoteOk(value: T): RemoteResult { return { ok: true, value } } -function remoteFailure(error: WorkspaceError): RemoteResult { +function remoteFailure(error: RemoteFailure): RemoteResult { return { ok: false, error } } @@ -250,7 +250,7 @@ describe('Workspace Controller Client apply', () => { phase: 'ready', state: 'error', items: [{ workspaceId: 'fresh' }], - error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' }, + error: { code: 'gateway/internal', message: 'Workspace state stream emitted more than one opening snapshot' }, }) }) @@ -445,40 +445,27 @@ describe('WorkspaceController', () => { it('maps generated business failures to the command facade errors', async () => { const remote = new CommandWorkspaceRemote() const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote)) - const missingWorkspace: WorkspaceError = { - code: 'workspace-not-found', - message: 'gone', - details: { workspaceId: wid('missing') }, - } - const missingSession: WorkspaceError = { - code: 'session-not-found', - message: 'missing session', - details: { sessionId: sid('session') }, - } + const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) + const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) - remote.create.mockResolvedValueOnce(remoteFailure({ - code: 'workspace-invalid-path', - message: 'missing path', - details: { path: '/missing' }, - })) + remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) - await expect(create).rejects.toThrow('workspace-invalid-path: missing path') + await expect(create).rejects.toThrow('workspace/invalid-path: missing path') remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace)) - await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone') + await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone') remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace)) - await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone') + await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone') remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace)) - await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone') + await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone') remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession)) - await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session') - remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({ - code: 'workspace-move-invalid', - message: 'invalid move', - details: { workspaceId: wid('missing'), sessionId: sid('session') }, - })) + await expect(controller.archiveSession(sid('session'))) + .rejects.toThrow('workspace session archive failed: session/not-found: missing session') + remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError( + 'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') }, + ))) await expect(controller.insertSessionBefore(wid('missing'), sid('session'))) - .rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move') + .rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move') }) }) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 88e2e65964..dab997847b 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import WorkspaceController from '../src/index.ts' @@ -14,6 +14,12 @@ import { WorkspaceFeed } from '../src/feed.ts' import type { WorkspaceFollowFrame } from '../src/types.ts' import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'fixture/failure': {} + } +} + const roots: Context[] = [] afterEach(async () => { @@ -94,34 +100,29 @@ describe('WorkspaceController commands', () => { const second = await controller.create({ path: stageDir(root, 'second') }) await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({ - failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } }, + code: 'workspace/invalid-path', + details: { path: join(root, 'missing') }, }) expect(existsSync(join(root, 'missing'))).toBe(false) await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: ' ' })) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' }) await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' })) - .rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } }) + .rejects.toMatchObject({ code: 'workspace/name-conflict' }) await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId })) - .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + .rejects.toMatchObject({ code: 'workspace/not-found' }) }) it('preserves Remote failures and propagates unexpected registry failures', async () => { const { controller, ctx, root } = await harness() - const remoteFailure = new TypertRemoteFailure({ - code: 'fixture-failure', - message: 'already mapped', - details: {}, - }) + const remoteFailure = new RemoteError('fixture/failure', 'already mapped', {}) const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath') .mockRejectedValueOnce(remoteFailure) .mockRejectedValueOnce('plain failure') await expect(controller.create({ path: stageDir(root, 'remote-failure') })) .rejects.toBe(remoteFailure) const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') }) - await expect(plainFailure).rejects.toMatchObject({ - failure: { code: 'workspace-invalid-path' }, - }) + await expect(plainFailure).rejects.toMatchObject({ code: 'workspace/invalid-path' }) await expect(plainFailure).rejects.toThrow('plain failure') resolveByPath.mockRestore() @@ -168,7 +169,7 @@ describe('WorkspaceController commands', () => { gate.resolve(undefined) await blocker await expect(deletion).resolves.toEqual({ deleted: true }) - await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + await expect(staleRename).rejects.toMatchObject({ code: 'workspace/not-found' }) }) it('reorders Workspaces and Sessions and archives only known Sessions', async () => { @@ -182,7 +183,7 @@ describe('WorkspaceController commands', () => { workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId], }) await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId })) - .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + .rejects.toMatchObject({ code: 'workspace/not-found' }) const session = ctx.sessions.create(SessionId('session-one'), { meta: { cwd: first.workspace.path }, @@ -197,26 +198,24 @@ describe('WorkspaceController commands', () => { await expect(controller.insertSessionBefore({ workspaceId: first.workspace.workspaceId, sessionId: SessionId('missing-session'), - })).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } }) + })).rejects.toMatchObject({ code: 'workspace/move-invalid' }) await expect(controller.insertSessionBefore({ workspaceId: first.workspace.workspaceId, sessionId: session.id, beforeSessionId: SessionId('missing-anchor'), })).rejects.toMatchObject({ - failure: { - code: 'workspace-move-invalid', - details: { beforeSessionId: 'missing-anchor' }, - }, + code: 'workspace/move-invalid', + details: { beforeSessionId: 'missing-anchor' }, }) await expect(controller.insertSessionBefore({ workspaceId: 'missing' as WorkspaceId, sessionId: session.id, - })).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + })).rejects.toMatchObject({ code: 'workspace/not-found' }) await expect(controller.archiveSession({ sessionId: session.id })) .resolves.toEqual({ archivedSessionIds: [session.id] }) await expect(controller.archiveSession({ sessionId: SessionId('unknown') })) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) }) }) diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 44e7595f34..41e384c662 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -2,8 +2,6 @@ export type { ClientRequest, - RpcError, - RpcErrorCode, RpcMessage, RpcRequest, RpcResponse, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 17b3a006c4..70f7cbf8fc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1806,7 +1806,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'settings-rejected', + code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', details: { ns }, }, @@ -1816,7 +1816,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'settings-rejected', + code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', details: { ns }, }, @@ -1827,7 +1827,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'settings-rejected', + code: 'settings/rejected', message: 'fixture: no settings namespaces are registered', details: { ns }, }, @@ -1844,7 +1844,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', message: `agent preset "${agentPreset}" ships with the deployment`, details: { agentPreset, reason: 'it ships with the deployment' }, }, @@ -2026,7 +2026,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ): Promise> | undefined => { if (summaryOf(request.sessionId) !== undefined) return undefined return sessionErr({ - code: 'session-not-found', + code: 'session/not-found', message: `no session ${request.sessionId}`, details: { sessionId: request.sessionId }, }) @@ -2079,12 +2079,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const goalFailure = (message: string): RpcResult => ({ ok: false, - error: { code: 'internal', message, details: {} }, + error: { code: 'gateway/internal', message, details: {} }, }) const requireGoalSession = (id: SessionId): RpcResult | undefined => ( summaryOf(id) === undefined - ? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } } + ? { ok: false, error: { code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } } } : undefined ) @@ -2273,7 +2273,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (children === undefined) { return { ok: false, - error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, + error: { code: 'directory-picker/unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, } } return { @@ -2292,13 +2292,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { createDirectory(parent: string, name: string): ConnectionRpcResult { const children = childrenOf(parent) if (children === undefined) { - return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } } + return { ok: false, error: { code: 'directory-picker/create-failed', message: `missing parent ${parent}`, details: { path: parent } } } } // Same root special case as list's entry paths: a plain join under '/' // would mint '//name' and fork the tree's identity. const target = parent === '/' ? `/${name}` : `${parent}/${name}` if (children.includes(name)) { - return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } } + return { ok: false, error: { code: 'directory-picker/exists', message: `${target} already exists`, details: { path: target } } } } directoryTree.set(parent, [...children, name]) directoryTree.set(target, []) @@ -2428,7 +2428,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', message: `unknown agent preset "${agentPreset}"`, details: { agentPreset, available: [...fixturePresets.keys()] }, }, @@ -2442,7 +2442,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', message: `unknown agent preset "${from}"`, details: { agentPreset: from, available: [...fixturePresets.keys()] }, }, @@ -2452,7 +2452,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', message: `agent preset "${id}" already exists`, details: { agentPreset: id, reason: 'already exists' }, }, @@ -2466,7 +2466,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', message: `agent preset "${id}" ships with the deployment`, details: { agentPreset: id, reason: 'it ships with the deployment' }, }, @@ -2724,7 +2724,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { search: (request, signal) => { if (signal.aborted) { return sessionErr({ - code: 'cancelled', + code: 'gateway/cancelled', message: 'fixture session search was aborted', details: {}, }) @@ -2766,7 +2766,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : workspaces.find(w => w.workspaceId === request.workspaceId) if (request.workspaceId !== undefined && workspace === undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -2784,7 +2784,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionId: SessionId, workspaceId: WorkspaceId, ): Promise> => sessionErr({ - code: 'workspace-attach-failed' as const, + code: 'session/workspace-attach-failed' as const, message: `fixture rejected Workspace attachment for ${sessionId}`, details: { sessionId, workspaceId }, }) @@ -2793,7 +2793,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (existing !== undefined) { if (existing.cwd !== cwd) { return sessionErr({ - code: 'session-conflict', + code: 'session/conflict', message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, }) @@ -2834,7 +2834,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const normalized = title.trim().replace(/\s+/g, ' ') if (normalized.length === 0) { return sessionErr({ - code: 'title-invalid', + code: 'session/title-invalid', message: 'session title must contain visible characters', details: { sessionId }, }) @@ -2853,7 +2853,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const source = summaryOf(sessionId) if (source === undefined) { return sessionErr({ - code: 'session-not-found', + code: 'session/not-found', message: `no session ${sessionId}`, details: { sessionId }, }) @@ -2869,7 +2869,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : undefined) if (boundary === undefined) { return sessionErr({ - code: 'fork-unavailable', + code: 'session/fork-unavailable', message: atSeq !== undefined && atSeq <= lastSeq ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` : `session ${sessionId} has no completed turn`, @@ -2923,18 +2923,18 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const { sessionId: id, mode, content } = request const summary = summaryOf(id) if (summary === undefined) { - return sessionErr({ code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + return sessionErr({ code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } }) } if (options.rejectPrompt) { if (content.some(block => block.type === 'image')) { return sessionErr({ - code: 'attachment-error', + code: 'session/attachment-invalid', message: 'fixture: image side exceeds the deployment limit', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, }) } return sessionErr({ - code: 'agent-busy', + code: 'session/agent-busy', message: 'fixture: prompt rejected before acceptance', details: { reason: 'fixture-prompt-rejection' }, }) @@ -3029,7 +3029,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const stored = attachments.get(String(request.attachmentId)) if (stored === undefined) { return sessionErr({ - code: 'attachment-error', + code: 'session/attachment-invalid', message: 'fixture attachment missing', details: { reason: 'ATTACHMENT_NOT_FOUND' }, }) @@ -3039,7 +3039,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { String(request.attachmentId), )) { return sessionErr({ - code: 'attachment-error', + code: 'session/attachment-invalid', message: 'fixture attachment is not referenced by this session', details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, }) @@ -3047,7 +3047,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return sessionOk(stored) }, updateQueue: request => sessionErr({ - code: 'queue-item-not-found', + code: 'session/queue-item-not-found', message: 'fixture has no pending queue item', details: { itemId: request.itemId }, }), @@ -3226,7 +3226,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'invocation-unavailable', + code: 'gateway/invocation-unavailable', message: 'fixture Remote event result identifies no active event stream', details: {}, }, @@ -3269,7 +3269,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) if (workspace === undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -3277,7 +3277,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const title = request.title.trim() if (title === '') { return sessionErr({ - code: 'bad-request', + code: 'gateway/bad-request', message: 'Workspace rename requires a non-blank title', details: {}, }) @@ -3285,7 +3285,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (title !== workspace.title) { if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) { return sessionErr({ - code: 'workspace-name-conflict', + code: 'workspace/name-conflict', message: `workspace name '${title}' is already in use`, details: { name: title }, }) @@ -3300,7 +3300,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) if (index === -1) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -3321,7 +3321,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : undefined if (missing !== undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${missing}`, details: { workspaceId: missing }, }) @@ -3348,7 +3348,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) if (workspace === undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -3356,7 +3356,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (!workspace.sessionIds.includes(request.sessionId) || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) { return sessionErr({ - code: 'workspace-move-invalid', + code: 'workspace/move-invalid', message: `session or anchor is not accounted by workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId, @@ -3378,7 +3378,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { archiveSession: (request) => { if (summaryOf(request.sessionId) === undefined) { return sessionErr({ - code: 'session-not-found', + code: 'session/not-found', message: `no session ${request.sessionId}`, details: { sessionId: request.sessionId }, }) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 36b9025d64..325834b69e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -29,7 +29,7 @@ declare module '@deepseek-ai/cordis' { // ---- Browser-safe protocol and shared value re-exports ---- export type { MessageId, - RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, + RpcRequest, RpcResponse, RpcResult, ClientRequest, ServerResponse, RpcMessage, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a00277d813..a9bbc72954 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -230,7 +230,7 @@ function rpcFetchHandler( const message: ClientRequest = envelope.data if (message.method !== endpoint) { return errorResponse(message.rpcId, { - code: 'bad-request', + code: 'gateway/bad-request', message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, details: { issues: [] }, }) @@ -250,7 +250,7 @@ function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Resp const rawId = (body as { rpcId?: unknown } | null)?.rpcId const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID return errorResponse(rpcId, { - code: 'bad-request', + code: 'gateway/bad-request', message: 'invalid client-request message', details: { issues }, }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 6cbe86d837..12c8f198be 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,7 +1,6 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -27,32 +26,6 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } -/** Typed failure details used by Client Session adapters. */ -export interface RpcErrorDetailsMap { - 'bad-request': { issues: object[] } - 'cancelled': {} - 'session-not-found': { sessionId: SessionId } - 'invalid-time-zone': { value: string } - 'agent-preset-read-only': { agentPreset: string; reason: string } - 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } - 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } - 'agent-preset-invalid': { agentPreset: string; reason: string } - 'agent-busy': { reason: string } - 'internal': {} -} - -/** Error codes used by Client Session adapters. */ -export type RpcErrorCode = keyof RpcErrorDetailsMap - -/** Typed failure used by Client Session adapters. */ -export type RpcError = { - [Code in RpcErrorCode]: { - readonly code: Code - readonly message: string - readonly details: RpcErrorDetailsMap[Code] - } -}[RpcErrorCode] - /** Historical short name for a generic Connection result. */ export type RpcResult = ConnectionRpcResult @@ -65,7 +38,7 @@ export function transportError(error: unknown): RpcResult { return { ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: error instanceof Error ? error.message : String(error), details: {}, }, diff --git a/packages/client/connection/tests/api-helpers.client.spec.ts b/packages/client/connection/tests/api-helpers.client.spec.ts index 9e97cdab77..328fa8ede6 100644 --- a/packages/client/connection/tests/api-helpers.client.spec.ts +++ b/packages/client/connection/tests/api-helpers.client.spec.ts @@ -9,7 +9,7 @@ import { RpcId, resultOf, transportError } from '../src/client/api.ts' describe('transportError', () => { it('folds an Error to internal keeping the message, and stringifies non-Errors', () => { - expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'internal', message: '线断了', details: {} } }) + expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'gateway/internal', message: '线断了', details: {} } }) expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } }) }) }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index ea5c3a764b..ed7eee91b3 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -36,7 +36,7 @@ describe('createFixtureApi commands/skills', () => { it('rejects a catalog request for an unknown session', async () => { const { rpc } = createFixtureFaces() const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } }) - expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) }) it('executes a known command line: pure admission plus a followed lifecycle pair', async () => { @@ -76,7 +76,7 @@ describe('createFixtureApi commands/skills', () => { const missing = await rpc.call('/api', 'commands/execute', { args: { agentId: sid('fx-nope'), line: '/goal ship' }, }) - expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(missing).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) }) it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { @@ -165,7 +165,7 @@ describe('createFixtureApi commands/skills', () => { const missingSession = await rpc.call('/api', 'skills/list', { args: { request: { sessionId: sid('fx-nope') } }, }) - expect(missingSession).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(missingSession).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) }) }) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index b7e76ec767..7b5cbf0eca 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -664,7 +664,7 @@ describe('createFixtureApi', () => { const aborted = new AbortController() aborted.abort() await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal)) - .resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } }) + .resolves.toMatchObject({ result: { ok: false, error: { code: 'gateway/cancelled' } } }) }) it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { @@ -778,7 +778,7 @@ describe('createFixtureApi', () => { ]) { expect(result).toMatchObject({ ok: false, - error: { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' }, + error: { code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' }, }) } @@ -866,9 +866,9 @@ describe('createFixtureApi', () => { for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) - // Unknown session → session-not-found with the id echoed in details. + // Unknown session → session/not-found with the id echoed in details. const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'ghost' } } }) // Real prompt: replay starts (running flips true), cancel freezes it. const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] })) expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) @@ -1042,7 +1042,7 @@ describe('createFixtureApi', () => { clientId, eventId: question.eventId, outcome: { kind: 'result', value: { answers: {} } }, - })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } }) const remaining = await readResidentRemoteEvents(api, 1) expect(remaining.map(frame => frame.event)).toEqual(['approval/request']) @@ -1097,7 +1097,7 @@ describe('createFixtureApi', () => { clientId: await stream.clientId, eventId: approval.eventId, outcome: { kind: 'next' }, - })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } }) const remaining = await readResidentRemoteEvents(api, 1) expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) @@ -1172,11 +1172,11 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) const wsid = 'fx-ws-fixture' as WorkspaceId const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } }) await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) - expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace/name-conflict', details: { name: 'occupied' } } }) const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) if (!noop.result.ok) throw new Error('no-op rename failed') @@ -1210,10 +1210,10 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'fx-void' } } }) const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' })) - expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } }) + expect(blank.result).toMatchObject({ ok: false, error: { code: 'session/title-invalid', details: { sessionId: 'fx-alpha' } } }) const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' })) if (!renamed.result.ok) throw new Error('rename failed') @@ -1247,11 +1247,11 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const wsid = 'fx-ws-fixture' as WorkspaceId const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } }) const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) - expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { sessionId: 'fx-ghost' } } }) const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) - expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) if (!moved.result.ok) throw new Error('move failed') @@ -1276,7 +1276,7 @@ describe('createFixtureApi', () => { ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } }) const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) const frames = await consuming @@ -1309,7 +1309,7 @@ describe('createFixtureApi', () => { ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } }) const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId @@ -1384,7 +1384,7 @@ describe('createFixtureApi', () => { const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' })) expect(conflict.result).toMatchObject({ ok: false, - error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, + error: { code: 'session/conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, }) }) @@ -1416,7 +1416,7 @@ describe('createFixtureApi', () => { expect(conflict.result).toEqual({ ok: false, error: { - code: 'session-conflict', + code: 'session/conflict', message: `session ${existing.sessionId} already uses no cwd`, details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, }, @@ -1432,7 +1432,7 @@ describe('createFixtureApi', () => { })) expect(created.result).toMatchObject({ ok: false, - error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, + error: { code: 'session/workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, }) const listed = await api.sessions.list(req({})) const workspaces = await readWorkspaceBaseline(api.workspaceRemote) @@ -1444,7 +1444,7 @@ describe('createFixtureApi', () => { workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId, })) - expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(retried.result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) const afterRetry = await api.sessions.list(req({})) if (!afterRetry.result.ok) throw new Error('list failed') expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) @@ -1475,7 +1475,7 @@ describe('createFixtureApi', () => { mode: 'queue' as const, content: [{ type: 'text' as const, text: 'keep me' }], })) - expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + expect(prompt.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) const imagePrompt = await rejecting.sessions.prompt(req({ sessionId: real.result.value.sessionId, mode: 'queue' as const, @@ -1483,7 +1483,7 @@ describe('createFixtureApi', () => { })) expect(imagePrompt.result).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } }, }) }) @@ -1722,7 +1722,7 @@ describe('fixture Connection RPC', () => { mode: 'queue', content: [{ type: 'text', text: 'retain' }], }) - expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + expect(rejected.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) }) it('maps attach-failure and dropped-response query scenarios', async () => { @@ -1734,7 +1734,7 @@ describe('fixture Connection RPC', () => { }) expect(partialResult.result).toMatchObject({ ok: false, - error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, + error: { code: 'session/workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, }) vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 268b37e75c..6e638a19a9 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -403,7 +403,7 @@ describe('connection node half', () => { }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ rpcId: 'rpc-bad', - result: { ok: false, error: { code: 'bad-request' } }, + result: { ok: false, error: { code: 'gateway/bad-request' } }, }) for (const [request, status] of [ @@ -428,7 +428,7 @@ describe('connection node half', () => { await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, - result: { ok: false, error: { code: 'bad-request' } }, + result: { ok: false, error: { code: 'gateway/bad-request' } }, }) } diff --git a/packages/client/connection/tests/rpc-schema.host.spec.ts b/packages/client/connection/tests/rpc-schema.host.spec.ts index 1d34e58ac3..a3338905b2 100644 --- a/packages/client/connection/tests/rpc-schema.host.spec.ts +++ b/packages/client/connection/tests/rpc-schema.host.spec.ts @@ -20,11 +20,11 @@ describe('Connection RPC schema', () => { it('folds transport exceptions into an internal failure', () => { expect(transportError(new Error('wire down'))).toEqual({ ok: false, - error: { code: 'internal', message: 'wire down', details: {} }, + error: { code: 'gateway/internal', message: 'wire down', details: {} }, }) expect(transportError('raw')).toMatchObject({ ok: false, - error: { code: 'internal', message: 'raw' }, + error: { code: 'gateway/internal', message: 'raw' }, }) }) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index fe5a7295ff..8370696886 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -528,7 +528,7 @@ function detectBrowserLocale(locales: readonly LocaleDefinition[]): LocaleId | u } /** Required services: slot registration plus the settings transport. */ -export const inject = ['slots', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'remote', 'settingsScope'] /** * Client plugin body: provide the locale service with base dictionaries and diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index 71cf197ca3..c4a5b11284 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -38,7 +38,6 @@ async function bench() { revision += 1 return { ok: true as const, value: namespace() } }) - ctx.provide('connection', { api: {}, isLoopback: true } as never) const events = new TestRemote(ctx, { settings: { describe, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { @@ -72,7 +71,7 @@ describe('locale apply', () => { // setLocale/Host preference instead of leaning on a dead browser pin. it('declares the slot service', () => { - expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'remote', 'settingsScope']) }) it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index b75502116e..b1e6121051 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -40,7 +40,6 @@ async function bench(preference?: string) { revision += 1 return { ok: true as const, value: namespace() } }) - ctx.provide('connection', { api: {}, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx, { settings: { describe: describeRpc, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() diff --git a/packages/client/locale/tests/invariant.client.spec.ts b/packages/client/locale/tests/invariant.client.spec.ts index d9b1eb041e..11863dc533 100644 --- a/packages/client/locale/tests/invariant.client.spec.ts +++ b/packages/client/locale/tests/invariant.client.spec.ts @@ -21,10 +21,9 @@ describe('invariant companion', () => { it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'remote', 'settingsScope']) const ctx = new Context() new SlotRegistry(ctx) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // The settings row's transport and the forwarded-event port. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4926d7c001..836b4ae333 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -58,12 +58,11 @@ export const inject = [ * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const settingsWire = { settings: ctx.remote.settings } - const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) + const controller = new AgentPresetSettingsController(ctx, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(ctx.remote, () => { + const section = new AgentPresetSectionController(ctx, () => { void controller.load() for (const read of rosterReaders) read() }) @@ -105,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', 'uiWorkspace'], (scope: ClientContext) => { - const seat = new AgentPresetSeatController(scope.remote, () => { + const seat = new AgentPresetSeatController(scope, () => { const state = scope.sessions.list.getSnapshot() return state.current === undefined ? undefined : state.byId[state.current] }) 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 0f9f51d4eb..4c729197b9 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -10,11 +10,13 @@ * deployment default again, matching the workspace picker beside it. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { messageOf, presetOptions, readRoster } from './settings-store.ts' +import { presetOptions, readRoster } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' /** Hero-chip snapshot. */ @@ -53,7 +55,7 @@ export class AgentPresetSeatController { private staged: string | undefined constructor( - private readonly remote: Pick, + private readonly ctx: ClientContext, /** The session the hero is about to hand over to, when there is one. */ private readonly currentSession: () => Pick< SessionSummary, @@ -70,7 +72,7 @@ export class AgentPresetSeatController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await readRoster(this.remote) + const roster = await readRoster(this.ctx) if (!roster.ok) { this.set({ error: roster.error }) return @@ -155,35 +157,26 @@ export class AgentPresetSeatController { return } this.set({ busy: true, error: null }) - try { - const result = await this.remote.agentPresets.select(session.id, staged) - this.staged = undefined - if (!result.ok) { - const { error } = result - this.set({ - busy: false, - // A refusal carries its cause twice: `message` wraps it in the - // roster's own frame, which names the preset the surface reporting - // this already names, and a `reason` detail holds the same cause - // without it. Read by the detail rather than by the code, because - // every refusal that has a cause to give names it the same way. - error: 'reason' in error.details && typeof error.details.reason === 'string' - ? error.details.reason - : error.message, - current: presetOf(session) ?? '', - }) - return - } - // Consumed: the next new session opens on the deployment default again. - this.set({ busy: false, current: result.value }) - } catch (error) { - this.staged = undefined + const result = await this.ctx.remote.agentPresets.select(session.id, staged) + this.staged = undefined + if (!result.ok) { + const { error } = result this.set({ busy: false, - error: messageOf(error), + // A refusal carries its cause twice: `message` wraps it in the + // roster's own frame, which names the preset the surface reporting + // this already names, and a `reason` detail holds the same cause + // without it. Read by the detail rather than by the code, because + // every refusal that has a cause to give names it the same way. + error: 'reason' in error.details && typeof error.details.reason === 'string' + ? error.details.reason + : error.message, current: presetOf(session) ?? '', }) + return } + // Consumed: the next new session opens on the deployment default again. + this.set({ busy: false, current: result.value }) } } 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 43db65686d..d63ee2176c 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,9 +14,11 @@ * more than the row it targeted. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' +import { beginRosterRead, writeDefaultPreset } from './settings-store.ts' /** Ids a preset directory may be named, mirroring the host's own rule. */ const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ @@ -133,7 +135,7 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly remote: Pick, + private readonly ctx: ClientContext, /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is @@ -167,13 +169,13 @@ export class AgentPresetSectionController { // Issued together: one round trip decides the page, and a load that waited // for them in turn would hold the section in `loading` twice as long, // where a concurrent reload silently returns instead of refreshing. - const opener = this.remote.settings.canOpenAgentPresetDirectory() - const roster = await beginRosterRead(this.remote, this.store) + const opener = this.ctx.remote.settings.canOpenAgentPresetDirectory() + const roster = await beginRosterRead(this.ctx, this.store) // A refused describe leaves the reveal-the-path path, which needs no opener. - const described = await opener.catch(() => undefined) + const described = await opener if (roster === undefined) return const { presets, authorable } = roster - const hasDocument = described?.ok === true && described.value + const hasDocument = described.ok && described.value if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) @@ -201,17 +203,13 @@ export class AgentPresetSectionController { */ async view(id: string): Promise { this.set({ error: null }) - try { - const result = await this.remote.agentPresets.read(id) - if (!result.ok) { - this.set({ error: result.error.message }) - return - } - const { name, content } = result.value - this.set({ view: { id, title: name ?? id, content } }) - } catch (error) { - this.set({ error: messageOf(error) }) + const result = await this.ctx.remote.agentPresets.read(id) + if (!result.ok) { + this.set({ error: result.error.message }) + return } + const { name, content } = result.value + this.set({ view: { id, title: name ?? id, content } }) } /** Close the read-only viewer. */ @@ -263,27 +261,23 @@ export class AgentPresetSectionController { if (draft === null || draft.saving) return if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return this.patchCopy({ saving: true, error: null }) - try { - const name = draft.name.trim() - // Every declared parameter is passed even when optional: the Remote face - // checks arity against the declaration and rejects a short call. An - // empty display name goes as `undefined` — absent rather than empty, so - // the host falls back to the id instead of labelling the row with ''. - const result = await this.remote.agentPresets.copy( - draft.from, draft.id, name === '' ? undefined : name) - if (!result.ok) { - this.patchCopy({ saving: false, error: result.error.message }) - return - } - this.set({ copy: null }) - await this.load() - this.rosterChanged() - // A preset is its files from here on (the dialog collected nothing - // else), so landing in them is the completion, not a follow-up. - await this.openLocation(draft.id) - } catch (error) { - this.patchCopy({ saving: false, error: messageOf(error) }) + const name = draft.name.trim() + // Every declared parameter is passed even when optional: the Remote face + // checks arity against the declaration and rejects a short call. An + // empty display name goes as `undefined` — absent rather than empty, so + // the host falls back to the id instead of labelling the row with ''. + const result = await this.ctx.remote.agentPresets.copy( + draft.from, draft.id, name === '' ? undefined : name) + if (!result.ok) { + this.patchCopy({ saving: false, error: result.error.message }) + return } + this.set({ copy: null }) + await this.load() + this.rosterChanged() + // A preset is its files from here on (the dialog collected nothing + // else), so landing in them is the completion, not a follow-up. + await this.openLocation(draft.id) } /** @@ -293,18 +287,14 @@ export class AgentPresetSectionController { * @returns once the host answered and the page reflects it. */ async openLocation(id: string): Promise { - try { - const result = await this.remote.settings.openAgentPresetDirectory(id) - if (!result.ok) { - this.set({ error: result.error.message }) - return - } - if (result.value.opened) return - const { path } = result.value - this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) - } catch (error) { - this.set({ error: messageOf(error) }) + const result = await this.ctx.remote.settings.openAgentPresetDirectory(id) + if (!result.ok) { + this.set({ error: result.error.message }) + return } + if (result.value.opened) return + const { path } = result.value + this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) } /** @@ -327,18 +317,14 @@ export class AgentPresetSectionController { const { pendingDelete, deleting } = this.store.getSnapshot() if (pendingDelete === null || deleting) return this.set({ deleting: true, error: null }) - try { - const result = await this.remote.agentPresets.deletePreset(pendingDelete) - if (!result.ok) { - this.set({ deleting: false, pendingDelete: null, error: result.error.message }) - return - } - this.set({ deleting: false, pendingDelete: null }) - await this.load() - this.rosterChanged() - } catch (error) { - this.set({ deleting: false, pendingDelete: null, error: messageOf(error) }) + const result = await this.ctx.remote.agentPresets.deletePreset(pendingDelete) + if (!result.ok) { + this.set({ deleting: false, pendingDelete: null, error: result.error.message }) + return } + this.set({ deleting: false, pendingDelete: null }) + await this.load() + this.rosterChanged() } /** @@ -348,7 +334,7 @@ export class AgentPresetSectionController { * @returns once the write settled and the roster was re-read. */ async makeDefault(id: string): Promise { - const failure = await writeDefaultPreset(this.remote, id) + const failure = await writeDefaultPreset(this.ctx, id) if (failure !== undefined) { this.set({ error: failure }) return 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 398b781e67..1792f204dd 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -7,51 +7,35 @@ * namespace's `default` field, which is what the host resolves at creation. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' -import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' -/** - * Human text for a rejected wire call. A transport failure rejects with an - * Error; a host or a runtime can reject with anything, and the surface still - * has to say something. - * @param error - the rejection value. - * @returns the message to show. - */ -export function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - /** * Persist one preset as the default for sessions created later. * * The default is a settings field rather than a preset property, so both the * General row and the management section write it here — one home for which * namespace and field the host resolves at session creation. - * @param api - the settings wire face. + * @param ctx - the browser plugin context carrying the Remote namespaces. * @param id - the preset to make default. * @returns the failure message, or undefined once the write landed. */ export async function writeDefaultPreset( - api: SettingsWireFace, + ctx: ClientContext, id: string, ): Promise { - let response - try { - response = await api.settings.update( - AGENT_PRESET_SETTINGS_NS, - { default: id }, - undefined, - ) - } catch (error) { - // The transport rejected rather than answering; the caller must be able to - // say so instead of the row silently snapping back. - return messageOf(error) - } + const response = await ctx.remote.settings.update( + AGENT_PRESET_SETTINGS_NS, + { default: id }, + undefined, + ) return response.ok ? undefined : response.error.message } @@ -76,27 +60,18 @@ export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; e const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false } /** - * Read the roster, folding both refusal shapes into one message. - * - * The wire refuses in two ways — the transport rejects, or it answers an - * `ok: false` envelope — and every surface treats them identically. Folding - * them here keeps each store's `load` about what it does with a roster rather - * than about how the call can fail. - * @param remote - the agent-preset Remote namespace. + * Read the roster, turning a refusal into the message every surface shows. + * @param ctx - the browser plugin context carrying the Remote namespaces. * @returns the roster, or the message to show in its place. */ -export async function readRoster(remote: Pick): Promise { - try { - const result = await remote.agentPresets.list() - if (result.ok) return { ok: true, value: result.value } - // Agent presets are optional: without that service every session uses the - // Host composition, so callers receive the same empty roster as a mounted - // service with no configured roots. - if (result.error.code === 'invocation-unavailable') return { ok: true, value: EMPTY_ROSTER } - return { ok: false, error: result.error.message } - } catch (error) { - return { ok: false, error: messageOf(error) } - } +export async function readRoster(ctx: ClientContext): Promise { + const result = await ctx.remote.agentPresets.list() + if (result.ok) return { ok: true, value: result.value } + // Agent presets are optional: without that service every session uses the + // Host composition, so callers receive the same empty roster as a mounted + // service with no configured roots. + if (result.error.code === 'gateway/invocation-unavailable') return { ok: true, value: EMPTY_ROSTER } + return { ok: false, error: result.error.message } } /** @@ -106,18 +81,18 @@ export async function readRoster(remote: Pick): Pr * A surface that gets `undefined` returns without touching its snapshot * further — either another read owns it, or this one already wrote the * failure. What differs between surfaces starts after this. - * @param remote - the agent-preset Remote namespace. + * @param ctx - the browser plugin context carrying the Remote namespaces. * @param store - the surface's own snapshot store. * @returns the roster, or undefined when the caller should return. */ export async function beginRosterRead( - remote: Pick, + ctx: ClientContext, store: SnapshotStore, ): Promise { const before = store.getSnapshot() if (before.status === 'loading') return undefined store.set({ ...before, status: 'loading', error: null }) - const roster = await readRoster(remote) + const roster = await readRoster(ctx) if (roster.ok) return roster.value store.set({ ...store.getSnapshot(), status: 'error', error: roster.error }) return undefined @@ -180,13 +155,11 @@ export class AgentPresetSettingsController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) /** - * @param api - the settings wire face (the default write). - * @param remote - the agent-preset Remote namespace (the roster read). + * @param ctx - the browser plugin context (the roster read and the default write). * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: SettingsWireFace, - private readonly remote: Pick, + private readonly ctx: ClientContext, private readonly describeFace: SettingsDescribeFace, ) {} @@ -201,7 +174,7 @@ export class AgentPresetSettingsController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await beginRosterRead(this.remote, this.store) + const roster = await beginRosterRead(this.ctx, this.store) if (roster === undefined) return const { presets } = roster const [first] = presets @@ -236,7 +209,7 @@ export class AgentPresetSettingsController { const before = this.store.getSnapshot() if (before.status === 'saving' || id === before.currentValue) return this.set({ status: 'saving', error: null, currentValue: id }) - const failure = await writeDefaultPreset(this.api, id) + const failure = await writeDefaultPreset(this.ctx, id) if (failure !== undefined) { this.set({ status: 'ready', currentValue: before.currentValue, error: failure }) return 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 c2488de4af..11bd8dcf6f 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' 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 { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SessionId } from '@deepseek-ai/dsh-session' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' @@ -115,7 +115,6 @@ async function bench() { } ctx.provide('remote.agentPresets', agentPresets as never) Object.assign(remote, { agentPresets }) - ctx.provide('connection', { isLoopback: true } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } @@ -581,8 +580,10 @@ describe('AgentPresetSeatController reconciliation', () => { it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => { const state: { current?: { id: SessionId; blank: boolean } } = {} const controller = new AgentPresetSeatController({ - agentPresets: { - list: () => Promise.resolve(ROSTER_ONE), + remote: { + agentPresets: { + list: () => Promise.resolve(ROSTER_ONE), + }, }, } as never, () => state.current) @@ -595,43 +596,35 @@ describe('AgentPresetSeatController reconciliation', () => { expect(controller.store.getSnapshot().current).toBe('') }) - it.each([ - { - name: 'RPC rejection', - select: () => Promise.resolve({ - ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} }, - }), - message: 'selection rejected', - }, - { - name: 'transport failure', - select: () => Promise.reject(new Error('transport failed')), - message: 'transport failed', - }, - ])('restores an empty current value after $name for an uncomposed Session', async ({ select, message }) => { + it('restores an empty current value after a refused switch for an uncomposed Session', async () => { + const select = () => Promise.resolve({ + ok: false as const, error: new RemoteError('gateway/internal', 'selection rejected', {}), + }) const controller = new AgentPresetSeatController({ - agentPresets: { select }, + remote: { agentPresets: { select } }, } as never, () => ({ id: SessionId('uncomposed'), blank: true })) await controller.select('minimal') expect(controller.store.getSnapshot()).toMatchObject({ - busy: false, current: '', error: message, + busy: false, current: '', error: 'selection rejected', }) }) it('keeps the bare cause of a mount failure, not the frame that names the preset again', async () => { const reason = 'failed to import loader entry ctx (@deepseek-ai/dsh-gone): Cannot find package' const controller = new AgentPresetSeatController({ - agentPresets: { - select: () => Promise.resolve({ - ok: false as const, - error: { - code: 'agent-preset-invalid', - message: `agent-presets: preset "broken" failed to mount: ${reason}`, - details: { agentPreset: 'broken', reason }, - }, - }), + remote: { + agentPresets: { + select: () => Promise.resolve({ + ok: false as const, + error: new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "broken" failed to mount: ${reason}`, + { agentPreset: 'broken', reason }, + ), + }), + }, }, } as never, () => ({ id: SessionId('uncomposed'), blank: true })) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 0b732c4cf5..0f3371df90 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,8 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -29,27 +30,19 @@ interface FakeOptions { failRemove?: string /** Reject `settings.update` with this message. */ failSettings?: string - /** Throw from `list` rather than answering, as a dead transport does. */ - throwList?: boolean - /** Throw from `read`, as a dead transport does. */ - throwRead?: boolean - /** Throw from `copy`, as a dead transport does. */ - throwCopy?: boolean - /** Throw from `openDocument`, as a dead transport does. */ - throwOpen?: boolean /** Whether the deployment configures a writable root. */ authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean - /** Reject the opener capability read, as a dead transport does. */ - throwCapability?: boolean + /** Refuse the opener capability read. */ + failCapability?: string /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) const remoteFail = (message: string) => - Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) + Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', message, {}) }) /** * The Remote namespace over an in-memory preset store: copies land, so the @@ -57,95 +50,93 @@ const remoteFail = (message: string) => * @param presets - the starting compositions by id. * @param defaultId - the preset a session with no choice gets. * @param options - failure injection and call recording. - * @returns the fake Remote namespace. + * @returns the fake plugin context carrying the Remote namespaces. */ -function fakeRemote( +function fakeCtx( presets: Map, defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): ClientContext { const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } return { - agentPresets: { - list: () => { - record('list', {}) - if (options.throwList === true) return Promise.reject(new Error('socket closed')) - if (options.failList !== undefined) return remoteFail(options.failList) - return remoteOk({ - presets: [...presets].map(([id, preset]) => ({ - id, trust: preset.trust, isDefault: id === defaultId.id, + remote: { + agentPresets: { + list: () => { + record('list', {}) + if (options.failList !== undefined) return remoteFail(options.failList) + return remoteOk({ + presets: [...presets].map(([id, preset]) => ({ + id, trust: preset.trust, isDefault: id === defaultId.id, + ...preset.name === undefined ? {} : { name: preset.name }, + })), + authorable: options.authorable ?? true, + }) + }, + read: (agentPreset: string) => { + record('read', { agentPreset }) + if (options.failRead !== undefined) return remoteFail(options.failRead) + const preset = presets.get(agentPreset) + /* v8 ignore next -- every test reads an id the fake store holds */ + if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`) + return remoteOk({ + agentPreset, + trust: preset.trust, + content: preset.content, ...preset.name === undefined ? {} : { name: preset.name }, - })), - authorable: options.authorable ?? true, - }) + }) + }, + // Arity is checked against the declaration, not against which arguments + // carry a value, so a short call rejects instead of answering. Reject + // one here too: the real face would, and a lenient double hid it once. + copy: (...args: [from: string, id: string, name?: string]) => { + if (args.length !== 3) { + return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`)) + } + const [from, id, name] = args + record('copy', { from, id, ...name === undefined ? {} : { name } }) + if (options.failCopy !== undefined) return remoteFail(options.failCopy) + const source = presets.get(from) + /* v8 ignore next -- every test copies a source the fake store holds */ + if (source === undefined) return remoteFail(`unknown preset ${from}`) + presets.set(id, { + trust: 'user', + content: source.content, + ...name === undefined ? {} : { name }, + }) + return remoteOk(undefined) + }, + deletePreset: async (id: string) => { + record('deletePreset', { id }) + await options.holdRemove + if (options.failRemove !== undefined) return await remoteFail(options.failRemove) + presets.delete(id) + return await remoteOk(undefined) + }, }, - read: (agentPreset: string) => { - record('read', { agentPreset }) - if (options.throwRead === true) return Promise.reject(new Error('socket closed')) - if (options.failRead !== undefined) return remoteFail(options.failRead) - const preset = presets.get(agentPreset) - /* v8 ignore next -- every test reads an id the fake store holds */ - if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`) - return remoteOk({ - agentPreset, - trust: preset.trust, - content: preset.content, - ...preset.name === undefined ? {} : { name: preset.name }, - }) - }, - // Arity is checked against the declaration, not against which arguments - // carry a value, so a short call rejects instead of answering. Reject - // one here too: the real face would, and a lenient double hid it once. - copy: (...args: [from: string, id: string, name?: string]) => { - if (args.length !== 3) { - return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`)) - } - const [from, id, name] = args - record('copy', { from, id, ...name === undefined ? {} : { name } }) - if (options.throwCopy === true) return Promise.reject(new Error('socket closed')) - if (options.failCopy !== undefined) return remoteFail(options.failCopy) - const source = presets.get(from) - /* v8 ignore next -- every test copies a source the fake store holds */ - if (source === undefined) return remoteFail(`unknown preset ${from}`) - presets.set(id, { - trust: 'user', - content: source.content, - ...name === undefined ? {} : { name }, - }) - return remoteOk(undefined) - }, - deletePreset: async (id: string) => { - record('deletePreset', { id }) - await options.holdRemove - if (options.failRemove !== undefined) return await remoteFail(options.failRemove) - presets.delete(id) - return await remoteOk(undefined) + settings: { + canOpenAgentPresetDirectory: () => { + record('canOpenAgentPresetDirectory', {}) + return options.failCapability === undefined + ? remoteOk(options.hasDocument ?? true) + : remoteFail(options.failCapability) + }, + update: (ns: string, patch: { default?: string }) => { + record('settings.update', { ns, patch }) + if (options.failSettings !== undefined) return remoteFail(options.failSettings) + /* v8 ignore next -- the controller only ever sets `default` */ + defaultId.id = patch.default ?? defaultId.id + return remoteOk({}) + }, + openAgentPresetDirectory: (agentPreset: string) => { + record('openAgentPresetDirectory', { agentPreset }) + if (options.failOpen !== undefined) return remoteFail(options.failOpen) + return (options.hasDocument ?? true) + ? remoteOk({ opened: true }) + : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) + }, }, }, - settings: { - canOpenAgentPresetDirectory: () => { - record('canOpenAgentPresetDirectory', {}) - return options.throwCapability === true - ? Promise.reject(new Error('socket closed')) - : remoteOk(options.hasDocument ?? true) - }, - update: (ns: string, patch: { default?: string }) => { - record('settings.update', { ns, patch }) - if (options.failSettings !== undefined) return remoteFail(options.failSettings) - /* v8 ignore next -- the controller only ever sets `default` */ - defaultId.id = patch.default ?? defaultId.id - return remoteOk({}) - }, - openAgentPresetDirectory: (agentPreset: string) => { - record('openAgentPresetDirectory', { agentPreset }) - if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) - if (options.failOpen !== undefined) return remoteFail(options.failOpen) - return (options.hasDocument ?? true) - ? remoteOk({ opened: true }) - : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) - }, - }, - } as unknown as Pick + } as unknown as ClientContext } function seed(): Map { @@ -162,7 +153,7 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeRemote(presets, defaultId, wired), + fakeCtx(presets, defaultId, wired), () => { rosterChanges += 1 }, ) return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges } @@ -175,8 +166,8 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft { } describe('loading the roster', () => { - it('still lists the roster when the opener capability cannot be read', async () => { - const { controller } = harness({ throwCapability: true }) + it('still lists the roster when the opener capability is refused', async () => { + const { controller } = harness({ failCapability: 'no opener here' }) await controller.load() @@ -228,14 +219,6 @@ describe('loading the roster', () => { expect(state.error).toBe('not for you') }) - it('folds a dead transport into the same error surface', async () => { - const { controller } = harness({ throwList: true }) - - await controller.load() - - expect(controller.store.getSnapshot().status).toBe('error') - expect(controller.store.getSnapshot().error).toContain('socket closed') - }) }) describe('the read-only viewer', () => { @@ -280,14 +263,6 @@ describe('the read-only viewer', () => { expect(controller.store.getSnapshot().error).toBe('no peeking') }) - it('folds a dead transport into the same error surface', async () => { - const { controller } = harness({ throwRead: true }) - await controller.load() - - await controller.view('standard') - - expect(controller.store.getSnapshot().error).toContain('socket closed') - }) }) describe('the copy dialog', () => { @@ -423,17 +398,6 @@ describe('submitting a copy', () => { expect(rosterChanges()).toBe(0) }) - it('folds a dead transport into the dialog error', async () => { - const { controller } = harness({ throwCopy: true }) - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - - await controller.confirmCopy() - - expect(copyOf(controller).error).toContain('socket closed') - }) - it('refuses to submit while blocked or already saving', async () => { const { controller, calls } = harness() await controller.load() @@ -486,14 +450,6 @@ describe('the location action', () => { expect(controller.store.getSnapshot().error).toBe('not yours') }) - it('folds a dead transport into the same error surface', async () => { - const { controller } = harness({ throwOpen: true }) - await controller.load() - - await controller.openLocation('mine') - - expect(controller.store.getSnapshot().error).toContain('socket closed') - }) }) describe('deleting', () => { @@ -552,25 +508,6 @@ describe('deleting', () => { expect(state.deleting).toBe(false) }) - it('folds a dead transport into the same error surface', async () => { - const { controller, presets } = harness() - await controller.load() - presets.clear() - const broken = new AgentPresetSectionController( - { - agentPresets: { - list: () => Promise.reject(new Error('gone')), - deletePreset: () => Promise.reject(new Error('socket closed')), - }, - settings: {}, - } as unknown as Pick, - ) - broken.confirmDelete('mine') - - await broken.remove() - - expect(broken.store.getSnapshot().error).toContain('socket closed') - }) }) describe('a controller with no roster listener', () => { @@ -580,7 +517,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeRemote(presets, defaultId)) + fakeCtx(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 4d8544dfcf..690dee35e5 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -6,24 +6,19 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { RemoteErrorCode } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, } from '../src/client/settings-store.ts' -/** The two faces the row reads: the roster Remote and the settings wire. */ -interface FakeWire { - api: SettingsWireFace - remote: Pick -} - -/** Controller over a real mirror derived from the same fake wire. */ -function derivedController(wire: FakeWire) { - return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api)) +/** Controller over a real mirror derived from the same scripted context. */ +function derivedController(ctx: ClientContext) { + return new AgentPresetSettingsController(ctx, new SettingsDescribeMirror(ctx)) } import { AgentPresetSeatController } from '../src/client/seat-store.ts' @@ -34,60 +29,58 @@ interface Recorded { ns: string; ops: unknown } /** A roster Remote answering a fixed set of rows, or refusing. */ function fakeRoster( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], - options: { failList?: string; failListCode?: string; throwOnList?: boolean } = {}, -): Pick { + options: { failList?: string; failListCode?: RemoteErrorCode; settings?: object } = {}, +): ClientContext { return { - agentPresets: { - list: () => { - if (options.throwOnList === true) return Promise.reject(new Error('socket closed')) - return Promise.resolve(options.failList === undefined - ? { ok: true as const, value: { presets, authorable: true } } - : { - ok: false as const, - error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} }, - }) + remote: { + ...options.settings === undefined ? {} : { settings: options.settings }, + agentPresets: { + list: () => { + return Promise.resolve(options.failList === undefined + ? { ok: true as const, value: { presets, authorable: true } } + : { + ok: false as const, + error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}), + }) + }, }, }, - } as unknown as Pick + } as unknown as ClientContext } -/** A wire whose roster and write outcome the test controls. */ +/** A context whose roster and settings write outcome the test controls. */ function fakeApi( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], options: { writes?: Recorded[] failWrite?: string failList?: string - failWriteWith?: Error readOnly?: boolean } = {}, -): FakeWire { - const api = { - settings: { - // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false - // and the row disables its control instead of offering a refused write. - describe: () => Promise.resolve({ - ok: true as const, - value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, - }), - update: (ns: string, patch: { default?: unknown }) => { - options.writes?.push({ ns, ops: patch }) - if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) - if (options.failWrite !== undefined) { - return Promise.resolve({ ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } }) - } - // A committed write moves the roster's default. - for (const preset of presets) { - preset.isDefault = preset.id === patch.default - } - return Promise.resolve({ ok: true as const, value: {} }) - }, +): ClientContext { + const settings = { + // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false + // and the row disables its control instead of offering a refused write. + describe: () => Promise.resolve({ + ok: true as const, + value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, + }), + update: (ns: string, patch: { default?: unknown }) => { + options.writes?.push({ ns, ops: patch }) + if (options.failWrite !== undefined) { + return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', options.failWrite, {}) }) + } + // A committed write moves the roster's default. + for (const preset of presets) { + preset.isDefault = preset.id === patch.default + } + return Promise.resolve({ ok: true as const, value: {} }) }, - } as unknown as SettingsWireFace - return { - api, - remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }), } + return fakeRoster(presets, { + settings, + ...options.failList === undefined ? {} : { failList: options.failList }, + }) } describe('the agent-preset settings controller', () => { @@ -162,13 +155,10 @@ describe('the agent-preset settings controller', () => { }) it('treats an unavailable optional namespace as an empty roster', async () => { - const controller = derivedController({ - api: {} as SettingsWireFace, - remote: fakeRoster([], { - failList: 'no active Remote method exports this endpoint', - failListCode: 'invocation-unavailable', - }), - }) + const controller = derivedController(fakeRoster([], { + failList: 'no active Remote method exports this endpoint', + failListCode: 'gateway/invocation-unavailable', + })) await controller.load() @@ -252,37 +242,6 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot().status).toBe('ready') }) - it('reads an Error\'s message and stringifies anything else', () => { - // A transport rejects with an Error, but a host or a runtime can reject - // with anything and the surface still has to say something. - expect(messageOf(new Error('boom'))).toBe('boom') - expect(messageOf({ code: 7 })).toBe('[object Object]') - }) - - it('reports a transport that rejects rather than answering', async () => { - const controller = derivedController({ - api: {} as SettingsWireFace, - remote: fakeRoster([], { throwOnList: true }), - }) - - await controller.load() - - expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) - }) - - it('reports a transport that rejects mid-write and keeps the old default showing', async () => { - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'mine', trust: 'user', isDefault: false }, - ], { failWriteWith: new Error('socket closed') })) - await controller.load() - - await controller.select('mine') - - // The value snaps back because the host never took it; a picker still - // showing "mine" would be claiming a default that does not exist. - expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) - }) }) describe('the new-session chip controller', () => { @@ -294,39 +253,36 @@ describe('the new-session chip controller', () => { writes?: Recorded[] failSelect?: string failList?: string - failListCode?: string - throwOn?: 'list' | 'select' + failListCode?: RemoteErrorCode } = {}, ): AgentPresetSeatController { - const remote = { - agentPresets: { - list: () => { - if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) - return Promise.resolve(options.failList === undefined - ? { ok: true as const, value: { presets, authorable: true } } - : { - ok: false as const, - error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} }, - }) - }, - select: (agentId: SessionId, agentPreset: string) => { - if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) - options.writes?.push({ ns: 'select', ops: agentPreset }) - return Promise.resolve(options.failSelect === undefined - ? { ok: true as const, value: agentPreset } - : { - ok: false as const, - error: { - code: 'agent-preset-locked', - message: options.failSelect, - details: { sessionId: agentId, agentPreset }, - }, - }) + const ctx = { + remote: { + agentPresets: { + list: () => { + return Promise.resolve(options.failList === undefined + ? { ok: true as const, value: { presets, authorable: true } } + : { + ok: false as const, + error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}), + }) + }, + select: (agentId: SessionId, agentPreset: string) => { + options.writes?.push({ ns: 'select', ops: agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { ok: true as const, value: agentPreset } + : { + ok: false as const, + error: new RemoteError('agent-preset/locked', options.failSelect, { + sessionId: agentId, agentPreset, + }), + }) + }, }, }, - } as unknown as Pick + } as unknown as ClientContext return new AgentPresetSeatController( - remote, + ctx, typeof current === 'function' ? current : () => current, ) } @@ -385,7 +341,7 @@ describe('the new-session chip controller', () => { it('opens on nothing when the optional namespace is unavailable', async () => { const controller = chip([], undefined, { failList: 'no active Remote method exports this endpoint', - failListCode: 'invocation-unavailable', + failListCode: 'gateway/invocation-unavailable', }) await controller.load() @@ -505,24 +461,6 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' }) }) - it('falls back to the default when the switch never reaches the host', async () => { - const controller = chip( - ROSTER, - { - id: 's1' as SessionId, - blank: true, - projectionValues: { agentPreset: 'standard' }, - }, - { throwOn: 'select' }, - ) - await controller.load() - - await controller.select('minimal') - - expect(controller.store.getSnapshot()) - .toMatchObject({ current: 'standard', busy: false, error: 'socket closed' }) - }) - it('ignores a pick while a switch is in flight', async () => { const writes: Recorded[] = [] const controller = chip(ROSTER, { @@ -559,21 +497,12 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] }) }) - it('reports a transport that rejects the roster read', async () => { - const controller = chip(ROSTER, undefined, { throwOn: 'list' }) - - await controller.load() - - expect(controller.store.getSnapshot().error).toBe('socket closed') - }) - it('degrades to a read-only row while the mirror holds no answer', async () => { - const controller = derivedController({ - // The roster answered; the mirror's read is what failed, so the row - // shows the current default without offering a write it never confirmed. - api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace, - remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]), - }) + // The roster answered; the mirror's read is what failed, so the row + // shows the current default without offering a write it never confirmed. + const controller = derivedController(fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }], { + settings: { describe: () => Promise.reject(new Error('socket closed')) }, + })) await controller.load() diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 9ceef64dd7..052c2b3bf8 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -5,7 +5,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { - SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, + RemoteError, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' @@ -127,7 +127,7 @@ describe('Chat inject API', () => { b.openWorkspacePath.mockResolvedValueOnce({ ok: false, - error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + error: new RemoteError('gateway/internal', 'xdg-open is not available', {}), }) await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available') await b.runtime.dispose() diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 1c795904b9..607bf69cb5 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ChatConversationViewNode, ConversationNode, diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 084d372ef0..01e17ed71e 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -5,8 +5,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' 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 { StatsLine, deriveStats, formatDuration, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' 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 bc3022cc3d..2acc40853e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -16,10 +16,9 @@ import type { import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { EMPTY_CONVERSATION_SNAPSHOT } 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 { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' @@ -2275,7 +2274,7 @@ describe('ChatView', () => { it('shows open error and loading states', () => { const h = makeHarness({}, { openState: 'error', - openError: { code: 'internal', message: 'boom' } as never, + openError: { code: 'gateway/internal', message: 'boom' } as never, }) const view = render() expect(view.getByText(/历史加载失败:boom/)).toBeTruthy() diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index cc1cf29745..57afd2abd8 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot, @@ -15,7 +15,6 @@ import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversa import type { DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget, } 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 { createChatStore } from '../src/client/stores.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index bef9f0c3bc..4a606fe607 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -395,7 +395,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * the outcome renders as a persistent flow node — the composer never * echoes it. A handler error result reports an error outcome so the * composer keeps the submission (draft and images) for correction. - * Transport failures throw. + * A refused call throws. */ private async execute( session: ClientSessionContext, @@ -441,9 +441,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * Fire-and-forget execute for the internal ('handled') paths. Outcomes are * NOT surfaced here: the host executor durably logs the command lifecycle * (`command/run`/`command/done`), and the mux-broadcast events render as a - * persistent flow node on every tab. Only a transport/admission failure — - * which never entered a handler and therefore never logged — falls back to - * the composer notice as immediate feedback. + * persistent flow node on every tab. Only an admission failure — which never + * entered a handler and therefore never logged — falls back to the composer + * notice as immediate feedback. */ private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void { void this.execute(session, line).then( @@ -468,7 +468,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { }) } - /** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */ + /** Route an admission failure to the session's composer notice channel (scope gone = attempt died with it). */ private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void { const actx = this.scopeFor(id) if (actx === undefined) return diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index def2baa09c..ae8ef7a884 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' 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 { RemoteError, 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' import type { CommandDescriptor } from '../src/client/directory.ts' @@ -44,8 +44,8 @@ interface BenchOptions { /** * Fold one programmed answer into the generated Remote face's outcome: a - * resolved value is the ok branch, a rejection is the transport failure the - * carrier reports in the error branch instead of throwing at the caller. + * resolved value is the ok branch, a rejection is the carrier failure the + * Remote face reports in the error branch instead of throwing at the caller. * @param produce - the scripted answer for one Remote method. * @returns the carried result the service reads. */ @@ -55,11 +55,7 @@ async function carried(produce: () => Promise) { } catch (error) { return { ok: false as const, - error: { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, + error: new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {}), } } } @@ -660,7 +656,7 @@ describe('detached admission notices', () => { expect(notices).toEqual([{ scope: sid('s1'), level: 'error', - text: 'command.execute failed: internal: network down', + text: 'command.execute failed: gateway/internal: network down', }]) }) diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index 6d4ee7a130..03940533c5 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -15,8 +15,9 @@ export function imageSizeText(bytes: number): string { } /** - * Product copy for a host attachment rejection (the `attachment-error` - * `details.reason`). User-solvable reasons name the limit and the way out; + * Product copy for a host attachment rejection (the `details.reason` of + * `session/attachment-invalid` or `subagent/attachment-unsupported`). + * User-solvable reasons name the limit and the way out; * reasons the user cannot act on fold into one send-failed line carrying the * reason code for a bug report. * @param t - the conversation-namespace translate. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2cddf23feb..6c7132425e 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -186,10 +186,10 @@ export class InputHub implements SessionInputResolver { /** * Steer every still-pending queued message into the running turn, in FIFO * order — the same strict-steer operation as the queue dock's per-row - * button. A turn closing mid-way (`steer-unavailable`) or a row already - * claimed by the agent (`queue-item-not-found`) converges silently, while a + * button. A turn closing mid-way (`session/steer-unavailable`) or a row already + * claimed by the agent (`session/queue-item-not-found`) converges silently, while a * genuine failure surfaces as one composer notice. Repeated triggers - * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found` + * (e.g. two rapid empty-draft chords) rely on that `session/queue-item-not-found` * convergence: the snapshot may still list a row the host already steered, * and the duplicate strict steer is a silent no-op. * @param session - the addressed host session. @@ -201,7 +201,7 @@ export class InputHub implements SessionInputResolver { for (const item of queued) { const result = await session.updateQueue(item.id, { kind: 'steer' }) if (result.ok) continue - if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + if (result.error.code === 'session/steer-unavailable' || result.error.code === 'session/queue-item-not-found') return shell.notify('error', this.t('queue.steerFailed')) return } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2fb0c99b51..fd59a492f0 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -315,7 +315,7 @@ export class ConversationController extends Service implements IConversation { if (!result.ok) { if ( action.kind === 'steer' - && (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') + && (result.error.code === 'session/steer-unavailable' || result.error.code === 'session/queue-item-not-found') ) return throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index d6126acdfd..d4c06d8bf3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -88,13 +88,15 @@ export function InputBar({ // and the user resubmits. A remount over a session whose machine still holds // an unresolved promptError deliberately re-announces it once — the failure // is still pending, and a transient banner is its only surface. Attachment - // rejections show product copy keyed by the wire reason; other codes are - // developer-facing and keep the raw message plus code. + // rejections show product copy keyed by the wire reason — whichever domain + // refused them; other codes are developer-facing and keep the raw message + // plus code. useEffect(() => { if (promptError === null) return - showToast(promptError.error.code === 'attachment-error' - ? attachmentErrorText(t, promptError.error.details.reason, imageLimits) - : `${promptError.error.message} (${promptError.error.code})`) + const { error } = promptError + showToast(error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-unsupported' + ? attachmentErrorText(t, error.details.reason, imageLimits) + : `${error.message} (${error.code})`) }, [promptError, showToast, t, imageLimits]) useEffect(() => { if (notice?.level === 'error') showToast(notice.text) 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 b4103dbda4..fb47038965 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -14,6 +14,7 @@ import { import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createConversationStore } from '../src/client/stores.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' usePinnedBrowserLanguages('zh-CN') @@ -112,7 +113,7 @@ describe('Conversation inject API', () => { }) b.sessionFake.prompt.mockResolvedValueOnce({ - ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, + ok: false, error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }), }) actions.setDraft('retry me') actions.submit() @@ -128,7 +129,7 @@ describe('Conversation inject API', () => { expect(b.inputApi(ROOT).state).toBe(state) b.sessionFake.cancel.mockResolvedValueOnce({ - ok: false, error: { code: 'internal', message: 'stop failed', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'stop failed', {}), }) b.composerApi(ROOT).stop!() await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() }) 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 ff4960f37c..fd99320d93 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -6,7 +6,9 @@ import { useState } from 'react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { + RemoteError, SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope, +} from '@deepseek-ai/dsh-client-test-runtime' import { InputHub } from '../src/client/input/hub.ts' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -178,7 +180,8 @@ describe('prompt rejection through the assembled composer', () => { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) const prompt = vi.fn(async () => ({ - ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + ok: false, + error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }), })) await runtime.sessions.add({ id: SID, @@ -201,11 +204,11 @@ describe('prompt rejection through the assembled composer', () => { await runtime.sessions.updateSessionSnapshot(SID, (draft) => { draft.promptError = { op: 'send', - error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }), } }) const alert = await view.findByRole('alert') - expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)') + expect(alert.textContent).toContain('prompt rejected before acceptance (session/agent-busy)') await waitFor(() => { expect(shell.snapshot.draft).toBe('do not lose this') }) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index fea7fa315b..44022cb553 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -1,12 +1,11 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx' import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts' diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index dee570b52a..54be8975c3 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts' describe('HistoricalImageCache', () => { @@ -68,7 +68,7 @@ describe('HistoricalImageCache', () => { session: { readAttachment: () => Promise.resolve({ ok: false, - error: { code: 'attachment-error', message: 'missing', details: {} }, + error: new RemoteError('session/attachment-invalid', 'missing', { reason: 'missing' }), } as never), }, }) @@ -78,7 +78,7 @@ describe('HistoricalImageCache', () => { } as const expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) - await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-error: missing') + await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-invalid: missing') expect(cache.peek(sessionId, attachment)).toBeUndefined() expect(revoked).toHaveBeenCalledWith('blob:seeded') await runtime.dispose() diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 9becd7f109..3cdfce1abf 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -11,10 +11,12 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { $getRoot, $isTextNode } from 'lexical' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { + bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, + sessionSnapshot as sessionFixture, +} from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' -import { conversationSnapshot as conversationFixture, makeTranslate, sessionSnapshot as sessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -340,7 +342,7 @@ describe('image draft rail', () => { it('announces server attachment rejections as product copy, other codes as developer text', () => { const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', - error: { code: 'attachment-error', message: 'raw wire text', details: { reason } }, + error: new RemoteError('session/attachment-invalid', 'raw wire text', { reason }), }) const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') }) expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型') @@ -348,10 +350,22 @@ describe('image draft rail', () => { const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') }) expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)') cleanup() - const other = bench({ - promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } }, + // A subagent turn refuses images under its own code; the reason keys the + // same product copy, because the user cannot act on which domain refused. + const subagent = bench({ + promptError: { + op: 'send', + error: new RemoteError('subagent/attachment-unsupported', 'raw wire text', { + childSessionId: SID, reason: 'SUBAGENT_IMAGE_UNSUPPORTED', + }), + }, }) - expect(other.view.getByRole('alert').textContent).toContain('boom (internal)') + expect(subagent.view.getByRole('alert').textContent).toContain('子智能体会话暂不支持图片') + cleanup() + const other = bench({ + promptError: { op: 'send', error: new RemoteError('gateway/internal', 'boom', {}) }, + }) + expect(other.view.getByRole('alert').textContent).toContain('boom (gateway/internal)') }) it('marks the attachment slot unavailable while the composer is locked', () => { @@ -1224,10 +1238,12 @@ describe('strips and variants', () => { it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => { vi.useFakeTimers() try { - const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) + const send = bench({ + promptError: { op: 'send', error: new RemoteError('session/agent-busy', 'boom', { reason: 'boom' }) }, + }) // The toast body-portals (transformed ancestors must not trap it), so // queries go through the view's document-bound helpers. - expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)') + expect(send.view.getByRole('alert').textContent).toContain('boom (session/agent-busy)') expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() act(() => { vi.advanceTimersByTime(4000) }) expect(send.view.queryByRole('alert')).toBeNull() 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 a6582d42b8..9fbcaf92aa 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -5,7 +5,7 @@ // tag probe). import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' 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' @@ -52,33 +52,33 @@ describe('ConversationController', () => { it('folds Session business failures into callback rejections', async () => { const b = await bench() - b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never) - await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy') - b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) - await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') + b.prompt.mockResolvedValueOnce({ ok: false, error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }) } as never) + await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: session/agent-busy: busy') + b.cancel.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'nope', {}) } as never) + await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: gateway/internal: nope') b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'internal', message: 'broken', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'broken', {}), } as never) await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })) - .rejects.toThrow('conversation.updateQueue failed: internal: broken') + .rejects.toThrow('conversation.updateQueue failed: gateway/internal: broken') await b.runtime.dispose() }) it('treats strict-steer races as converged Queue delivery', async () => { const b = await bench() b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined() b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined() b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' })) - .rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed') + .rejects.toThrow('conversation.updateQueue failed: session/queue-item-not-found: claimed') await b.runtime.dispose() }) @@ -241,7 +241,7 @@ describe('sendSession submission echo', () => { const b = await echoBench() try { b.prompt.mockResolvedValueOnce({ - ok: false, error: { code: 'attachment-error', message: 'nope', details: {} }, + ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }), } as never) const [attachment] = b.root.createDraftImages([ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), @@ -399,7 +399,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { }) // The turn closes before the second row: the flush stops, silently. b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) @@ -411,7 +411,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { draft.queue = [row('q-3')] }) b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) }) @@ -425,7 +425,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { draft.queue = [row('q-1'), row('q-2')] }) b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'internal', message: 'broken', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'broken', {}), } as never) b.shell.steerQueue() await vi.waitFor(() => { diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index ed8a222e4f..1d3f4159f0 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -7,7 +7,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, makeTranslate, sessionSnapshot as sessionFixture, + bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -466,7 +466,7 @@ describe('ConversationRoot resident composer', () => { awaitingFirstTurn: true, promptError: { op: 'send', - error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, + error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }), }, }) diff --git a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx index a1853ef0c7..e79081b003 100644 --- a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx @@ -6,10 +6,9 @@ */ import { act, 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 { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/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 { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx' import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx' diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 3be17e7da4..d66a505ff2 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -7,7 +7,6 @@ * composing this plugin out of cordis.yml removes both surfaces entirely; * 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 { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -32,14 +31,13 @@ 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', 'uiConversation', 'connection', 'remote', 'remote.session'] +export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session'] /** * Client plugin body: register the dictionaries and the turn-tail entry. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const connection = ctx.get('connection') as ConnectionHandle const workspacePathOpen = createSnapshotStore(undefined) let requestedWorkspacePathOpen = false let capabilityRevision = 0 @@ -50,8 +48,6 @@ export function apply(ctx: ClientContext): void { const pending = ctx.remote.session.canOpenWorkspacePath() .then((result) => { if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value) - }, () => { - if (revision === capabilityRevision) workspacePathOpen.set(false) }) .finally(() => { if (pendingCapability === pending) pendingCapability = undefined @@ -77,7 +73,7 @@ export function apply(ctx: ClientContext): void { select: selectProducedFiles, locale: NS, inject: () => ({ - isLoopback: connection.isLoopback, + isLoopback: ctx.remote.$host.isLoopback, ensureWorkspacePathOpen, hooks: { workspacePathOpen }, }), 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 5a0043971e..56821c7ecd 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -20,7 +20,7 @@ import type { 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-chat/client' -import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps, } from '../src/client/ProducedFiles.tsx' @@ -578,16 +578,15 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - const generation = { getSnapshot: () => undefined, subscribe: () => () => {} } - ctx.provide('connection', { - isLoopback: false, - generation, - } as never) // ui-theme's Appearance row binds a durable scope through these two. const session = { canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), } - ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote', { + $on: () => () => {}, + $host: { home: undefined, isLoopback: false }, + session, + } as never) ctx.provide('remote.session', session as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() @@ -636,20 +635,20 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - ctx.provide('connection', { - isLoopback: true, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) const first = Promise.withResolvers<{ ok: true; value: boolean }>() const second = Promise.withResolvers<{ ok: true; value: boolean }>() - const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>() + const staleFailure = Promise.withResolvers<{ ok: false; error: RemoteError }>() const capability = vi.fn() .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) .mockReturnValueOnce(staleFailure.promise) - .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'offline', {}) }) const session = { canOpenWorkspacePath: capability } - ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote', { + $on: () => () => {}, + $host: { home: undefined, isLoopback: true }, + session, + } as never) ctx.provide('remote.session', session as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() @@ -671,7 +670,7 @@ describe('plugin registration', () => { ctx.emit('connection/reset') ctx.emit('connection/reset') - staleFailure.reject(new Error('stale offline')) + staleFailure.resolve({ ok: false, error: new RemoteError('gateway/internal', 'stale offline', {}) }) await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) await fiber.dispose() }) diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2fec719e06..c2c98cada5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -75,7 +75,7 @@ export function apply(ctx: ClientContext): void { const noCurrentGoal: GoalActionResult = { ok: false, - error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} }, + error: { code: 'no-current-goal', message: 'no current goal to mutate' }, } ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ diff --git a/packages/client/ui-goal/src/client/slots.ts b/packages/client/ui-goal/src/client/slots.ts index 66101c601d..a343b44fc9 100644 --- a/packages/client/ui-goal/src/client/slots.ts +++ b/packages/client/ui-goal/src/client/slots.ts @@ -7,14 +7,23 @@ * (callbacks from inject, live state from useProjection). */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' + +/** + * The one failure the strip reports without a wire call: the session projects + * no goal, so no CAS ref exists to address a mutation to. + */ +export interface GoalLocalFailure { + readonly ok: false + readonly error: { readonly code: 'no-current-goal'; readonly message: string } +} /** * Settled outcome of one goal mutation, rendered inline by the strip. The * strip renders the failure only — the mutated goal arrives through the * projection — so the success value stays unread here. */ -export type GoalActionResult = RemoteResult +export type GoalActionResult = RemoteResult | GoalLocalFailure /** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */ export interface GoalBarActions { 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 cb2cf0a4be..eb4618eea4 100644 --- a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx @@ -19,7 +19,8 @@ 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' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { GoalBarActions } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -49,7 +50,7 @@ function makeProjection(revision = 3): GoalProjection { /** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */ async function bench(options: { projection?: GoalProjection | null | undefined - failWith?: { code: string; message: string; details: object } + failWith?: RemoteFailure } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] @@ -183,17 +184,20 @@ describe('ui-goal browser plugin', () => { await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { - expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} } }) + expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } }) } expect(b.calls).toHaveLength(0) } }) it('forwards a Remote failure to the strip verbatim', async () => { - const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } }) + const b = await bench({ + projection: makeProjection(), + failWith: new RemoteError('gateway/internal', 'stale revision', {}), + }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) - expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } }) + expect(await verbs.onEdit('x')).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'stale revision' } }) }) it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx index 064b34ea9f..c4518fb757 100644 --- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GoalBar } from '../src/client/GoalBar.tsx' import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts' @@ -174,28 +174,32 @@ describe('GoalBar', () => { it('keeps the edit draft open and reports a failed save', async () => { const actions = makeActions() - actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision', details: {} } }) + actions.onEdit.mockResolvedValue({ + ok: false, error: new RemoteError('session/agent-busy', 'stale revision', { reason: 'stale revision' }), + }) render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) fireEvent.change(box, { target: { value: 'retry this draft' } }) fireEvent.click(screen.getByRole('button', { name: '保存目标' })) - expect((await screen.findByRole('alert')).textContent).toBe('stale revision (agent-busy)') + expect((await screen.findByRole('alert')).textContent).toBe('stale revision (session/agent-busy)') expect(screen.getByRole('textbox', { name: '目标内容' })).toHaveProperty('value', 'retry this draft') }) it('reports resume and clear failures without hiding the goal', async () => { const actions = makeActions() - actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed', details: {} } }) + actions.onResume.mockResolvedValue({ ok: false, error: new RemoteError('gateway/internal', 'resume failed', {}) }) const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) - expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)') + expect((await screen.findByRole('alert')).textContent).toBe('resume failed (gateway/internal)') - actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } }) + actions.onClear.mockResolvedValue({ + ok: false, error: new RemoteError('session/agent-busy', 'clear failed', { reason: 'clear failed' }), + }) rerender() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) - expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') + expect((await screen.findByRole('alert')).textContent).toBe('clear failed (session/agent-busy)') expect(screen.getByText('Ship the redesign')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) }) diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 8e793bc077..e02a2cccf4 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -7,40 +7,15 @@ * @module @deepseek-ai/dsh-client-ui-message-feedback/client/controller */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - MessageFeedbackDeleteResult, MessageFeedbackItem, - MessageFeedbackListResult, - MessageFeedbackPutResult, MessageFeedbackRating, } from '@deepseek-ai/dsh-message-feedback/types' -/** - * The three Remote calls this controller needs. The generated face wraps every - * business result in {@link RemoteResult}: a carrier failure arrives as the - * `ok: false` branch rather than a rejection, so this controller reads one - * envelope and never wraps a call to recover a transport error. - */ -export interface MessageFeedbackRemote { - list: (request: { sessionId: SessionId }) => Promise> - put: (request: { - sessionId: SessionId - messageId: MessageId - rating: MessageFeedbackRating - note?: string - ifVersion: MessageFeedbackItem['version'] | null - }) => Promise> - delete: (request: { - sessionId: SessionId - messageId: MessageId - ifVersion: MessageFeedbackItem['version'] - }) => Promise> -} - /** Load state of the one list read that seeds every per-message control. */ export type MessageFeedbackStatus = 'cold' | 'loading' | 'ready' | 'error' @@ -111,11 +86,11 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.remote.put({ + const carried = await this.ctx.remote.messageFeedback.put({ sessionId: this.sessionId, messageId, rating, @@ -265,7 +240,7 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.remote.delete({ + const carried = await this.ctx.remote.messageFeedback.delete({ sessionId: this.sessionId, messageId, ifVersion: observed.version, @@ -288,34 +263,26 @@ export class MessageFeedbackController implements HostObservable { - try { - const carried = await this.remote.list({ sessionId: this.sessionId }) - if (this.disposed) return OK - if (!carried.ok) { - this.publish({ status: 'error', items: this.view.items, error: carried.error.message }) - return carrierFailure(carried.error) - } - const result = carried.value - if (!result.ok) { - this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) }) - return fail(result.error.code) - } - const items = new Map() - for (const item of result.value.items) items.set(item.messageId, item) - this.publish({ status: 'ready', items, error: null }) - return OK - } catch (error) { - if (this.disposed) return OK - const message = error instanceof Error ? error.message : String(error) - this.publish({ status: 'error', items: this.view.items, error: message }) - return { ok: false, error: { code: 'transport', message } } + const carried = await this.ctx.remote.messageFeedback.list({ sessionId: this.sessionId }) + if (this.disposed) return OK + if (!carried.ok) { + this.publish({ status: 'error', items: this.view.items, error: carried.error.message }) + return carrierFailure(carried.error) } + const result = carried.value + if (!result.ok) { + this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) }) + return fail(result.error.code) + } + const items = new Map() + for (const item of result.value.items) items.set(item.messageId, item) + this.publish({ status: 'ready', items, error: null }) + return OK } /** * Serialize one mutation behind this Session's prior mutation so queued - * operations always compare against the committed version, and translate a - * transport throw into the same settled shape the controls already render. + * operations always compare against the committed version. */ private mutate( operation: () => Promise, @@ -331,17 +298,7 @@ export class MessageFeedbackController implements HostObservable { let controller = controllers.get(sessionId) if (controller === undefined) { - controller = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId) + controller = new MessageFeedbackController(ctx, sessionId) controllers.set(sessionId, controller) } return controller diff --git a/packages/client/ui-message-feedback/src/client/slots.ts b/packages/client/ui-message-feedback/src/client/slots.ts index 8fc7f9dde2..352ff56526 100644 --- a/packages/client/ui-message-feedback/src/client/slots.ts +++ b/packages/client/ui-message-feedback/src/client/slots.ts @@ -12,7 +12,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types' // Type-only: pulls this package's LocaleNamespaceMap merge (the 'feedback' seat). import type {} from './locales.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 bd894ebacf..96d8b21a44 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 @@ -14,7 +14,7 @@ import { cleanup } from '@testing-library/react' 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 { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types' import type { MessageFeedbackInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' diff --git a/packages/client/ui-message-feedback/tests/controller.client.spec.ts b/packages/client/ui-message-feedback/tests/controller.client.spec.ts index 7f7c9cdecb..1c389d1403 100644 --- a/packages/client/ui-message-feedback/tests/controller.client.spec.ts +++ b/packages/client/ui-message-feedback/tests/controller.client.spec.ts @@ -6,11 +6,13 @@ * serialize per Session, and a disposed controller stops publishing. */ import { describe, expect, it, vi } from 'vitest' -import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { MessageFeedbackItem, MessageFeedbackVersion, } from '@deepseek-ai/dsh-message-feedback/types' -import { MessageFeedbackController, type MessageFeedbackRemote } from '../src/client/controller.ts' +import { MessageFeedbackController } from '../src/client/controller.ts' const SESSION = 's-1' as SessionId const MSG = 'm-1' as MessageId @@ -37,10 +39,10 @@ type Script = { } /** - * A recording fake Remote. Scripts return the *business* result; this wraps it + * A recording fake context. Scripts return the *business* result; this wraps it * in the carrier envelope the generated face uses, so specs stay readable. A - * script may also return an already-enveloped `{ok:false,error:{code,message, - * details}}` to exercise a carrier failure. + * script may also return an already-enveloped `{ok:false,error:RemoteError}` to + * exercise a carrier failure. */ function fakeRemote(script: Script = {}) { const calls: { method: string; request: unknown }[] = [] @@ -53,21 +55,25 @@ function fakeRemote(script: Script = {}) { const business = real === undefined ? Promise.resolve(fallback) : real(request) return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise } - const remote = { - list: record('list', script.list, { ok: true, value: { items: [] } }), - put: record('put', script.put, { ok: true, value: item() }), - delete: record('delete', script.delete, { ok: true, value: { absent: true } }), - } as unknown as MessageFeedbackRemote - return { remote, calls } + const ctx = { + remote: { + messageFeedback: { + list: record('list', script.list, { ok: true, value: { items: [] } }), + put: record('put', script.put, { ok: true, value: item() }), + delete: record('delete', script.delete, { ok: true, value: { absent: true } }), + }, + }, + } as unknown as ClientContext + return { ctx, calls } } describe('MessageFeedbackController', () => { it('seeds the view from one list read and keys items by message id', async () => { const seeded = item({ note: 'good' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(controller.getSnapshot().status).toBe('cold') expect(await controller.ensure()).toEqual({ ok: true }) @@ -79,8 +85,8 @@ describe('MessageFeedbackController', () => { }) it('collapses concurrent loads onto one in-flight read', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()]) @@ -90,13 +96,13 @@ describe('MessageFeedbackController', () => { it('sends ifVersion null for a first rating and the observed version afterwards', async () => { const first = item({ version: version('v1') }) const second = item({ version: version('v2'), rating: 'negative' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ put: request => Promise.resolve({ ok: true, value: (request as { rating: string }).rating === 'positive' ? first : second, }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true }) expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true }) @@ -108,8 +114,8 @@ describe('MessageFeedbackController', () => { }) it('forwards an optional note and omits the field when absent', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) await controller.rate(MSG, 'positive', 'helpful') await controller.rate(OTHER, 'negative') @@ -121,13 +127,13 @@ describe('MessageFeedbackController', () => { it('reconciles a version conflict from the authoritative item without refetching', async () => { const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ put: () => Promise.resolve({ ok: false, error: { code: 'version-conflict', current: authoritative }, }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, @@ -139,14 +145,14 @@ describe('MessageFeedbackController', () => { }) it('drops the local item when a conflict reports the feedback is gone', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), delete: () => Promise.resolve({ ok: false, error: { code: 'version-conflict', current: null }, }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } }) @@ -154,10 +160,10 @@ describe('MessageFeedbackController', () => { }) it('deletes with the observed version and removes the item on success', async () => { - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.clear(MSG)).toEqual({ ok: true }) @@ -168,8 +174,8 @@ describe('MessageFeedbackController', () => { }) it('treats clearing an unrated message as already satisfied without a call', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.clear(MSG)).toEqual({ ok: true }) expect(calls.filter(call => call.method === 'delete')).toHaveLength(0) @@ -180,7 +186,7 @@ describe('MessageFeedbackController', () => { let overlapped = false const versions = [version('v1'), version('v2')] let index = 0 - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ put: async () => { inFlight += 1 if (inFlight > 1) overlapped = true @@ -191,7 +197,7 @@ describe('MessageFeedbackController', () => { return { ok: true, value: item({ version: next }) } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')]) @@ -202,10 +208,10 @@ describe('MessageFeedbackController', () => { }) it('publishes an error status when the list read is rejected by the Host', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(controller.getSnapshot()).toMatchObject({ @@ -214,31 +220,9 @@ describe('MessageFeedbackController', () => { }) }) - it('settles a transport throw as a result instead of rejecting', async () => { - const { remote } = fakeRemote({ list: () => Promise.reject(new Error('socket closed')) }) - const controller = new MessageFeedbackController(remote, SESSION) - - expect(await controller.ensure()).toEqual({ - ok: false, - error: { code: 'transport', message: 'socket closed' }, - }) - expect(controller.getSnapshot().status).toBe('error') - }) - - it('settles a mutation transport throw without corrupting the view', async () => { - const { remote } = fakeRemote({ put: () => Promise.reject(new Error('socket closed')) }) - const controller = new MessageFeedbackController(remote, SESSION) - - expect(await controller.rate(MSG, 'positive')).toEqual({ - ok: false, - error: { code: 'transport', message: 'socket closed' }, - }) - expect(controller.getSnapshot().items.has(MSG)).toBe(false) - }) - it('notifies subscribers on publication and stops after unsubscribe', async () => { - const { remote } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) const listener = vi.fn() const unsubscribe = controller.subscribe(listener) @@ -252,8 +236,8 @@ describe('MessageFeedbackController', () => { }) it('contains a throwing subscriber at the observable boundary', async () => { - const { remote } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) controller.subscribe(() => { throw new Error('subscriber exploded') }) const healthy = vi.fn() @@ -267,8 +251,8 @@ describe('MessageFeedbackController', () => { }) it('refuses mutations and stops publishing once disposed', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -289,20 +273,20 @@ describe('MessageFeedbackController', () => { ['note-too-large', 'the note is too long'], ] as const for (const [code, message] of codes) { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } }) expect(controller.getSnapshot().error).toBe(message) } }) it('falls back to the raw code for an unrecognized failure', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } }) expect(controller.getSnapshot().error).toBe('brand-new-code') @@ -311,13 +295,13 @@ describe('MessageFeedbackController', () => { it('publishes nothing when the list settles after disposal', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: async () => { await gate return { ok: true, value: { items: [item()] } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) const pending = controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -330,47 +314,11 @@ describe('MessageFeedbackController', () => { expect(listener).not.toHaveBeenCalled() }) - it('swallows a rejected list that settles after disposal', async () => { - let reject = (): void => {} - const gate = new Promise((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } }) - const { remote } = fakeRemote({ list: () => gate }) - const controller = new MessageFeedbackController(remote, SESSION) - const pending = controller.ensure() - - controller.dispose() - reject() - - expect(await pending).toEqual({ ok: true }) - expect(controller.getSnapshot().status).not.toBe('error') - }) - - it('preserves a non-Error list rejection as a diagnostic string', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. - 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: 'socket string' }, - }) - }) - - it('preserves a non-Error mutation rejection as a diagnostic string', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. - 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: 'nope' }, - }) - }) - it('propagates a failed load to a queued mutation without calling the wire', async () => { - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, @@ -381,23 +329,23 @@ describe('MessageFeedbackController', () => { it('keeps a later mutation running after an earlier one settles as a failure', async () => { let first = true - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ put: () => { if (first) { first = false - return Promise.reject(new Error('first blew up')) + return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) }) } return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) }) }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) const [a, b] = await Promise.all([ controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative'), ]) - expect(a).toMatchObject({ ok: false, error: { code: 'transport' } }) + expect(a).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) expect(b).toEqual({ ok: true }) expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative') }) @@ -408,14 +356,14 @@ describe('MessageFeedbackController', () => { // fiber unloads, and its authoritative item must not be published. let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }), put: async () => { await gate return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -432,14 +380,14 @@ describe('MessageFeedbackController', () => { it('drops a delete conflict reconciliation once disposed mid-flight', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), delete: async () => { await gate return { ok: false, error: { code: 'version-conflict', current: null } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const pending = controller.clear(MSG) @@ -455,11 +403,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => { const existing = item({ version: version('v3'), rating: 'positive' }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({ @@ -471,11 +419,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => { const existing = item({ version: version('v4') }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.clear(MSG)).toMatchObject({ @@ -489,10 +437,10 @@ describe('MessageFeedbackController', () => { // Regression: a control that rendered before the first list read holds no // item, so it passes note=undefined; that must not erase the stored note. const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true }) @@ -503,10 +451,10 @@ describe('MessageFeedbackController', () => { it('toggle retracts when the committed rating already matches', async () => { const stored = item({ version: version('v1'), rating: 'positive' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true }) @@ -519,10 +467,10 @@ describe('MessageFeedbackController', () => { // The click lands before any list read: the cold view knows no item, yet the // stored rating matches, so the toggle must retract rather than re-put. const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(controller.getSnapshot().status).toBe('cold') expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true }) @@ -532,10 +480,10 @@ describe('MessageFeedbackController', () => { it('toggle replaces the opposite rating and carries the note forward', async () => { const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true }) @@ -545,10 +493,10 @@ describe('MessageFeedbackController', () => { it('clearNote drops the note and keeps the rating', async () => { const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.clearNote(MSG)).toEqual({ ok: true }) @@ -558,10 +506,10 @@ describe('MessageFeedbackController', () => { }) it('clearNote is a no-op when there is no note to drop', async () => { - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.clearNote(MSG)).toEqual({ ok: true }) expect(calls.filter(c => c.method === 'put')).toHaveLength(0) @@ -573,7 +521,7 @@ describe('MessageFeedbackController', () => { const order: string[] = [] let releasePut = (): void => {} const putGate = new Promise((r) => { releasePut = r }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => { order.push('list') return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }) @@ -585,7 +533,7 @@ describe('MessageFeedbackController', () => { return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const rating = controller.rate(MSG, 'negative') @@ -605,14 +553,14 @@ describe('MessageFeedbackController', () => { const gate = new Promise((r) => { release = r }) let started = (): void => {} const listStarted = new Promise((r) => { started = r }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: async () => { started() await gate return { ok: true, value: { items: [] } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) const pending = controller.rate(MSG, 'positive') await listStarted @@ -626,49 +574,49 @@ describe('MessageFeedbackController', () => { it('renders a carrier failure from the Remote envelope', async () => { // The generated face folds transport faults into ok:false with a // RemoteFailure, so the controller reads them as values, not rejections. - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed', details: {} }, + error: new RemoteError('gateway/internal', 'socket closed', {}), }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toEqual({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed' }, + error: { code: 'gateway/internal', message: 'socket closed' }, }) expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) }) it('renders a carrier failure on a mutation without touching the view', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ put: () => Promise.resolve({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed', details: {} }, + error: new RemoteError('gateway/internal', 'socket closed', {}), }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed' }, + error: { code: 'gateway/internal', message: 'socket closed' }, }) expect(controller.getSnapshot().items.has(MSG)).toBe(false) }) it('renders a carrier failure on a delete', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), delete: () => Promise.resolve({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed', details: {} }, + error: new RemoteError('gateway/internal', 'socket closed', {}), }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() - expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } }) + expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) expect(controller.getSnapshot().items.has(MSG)).toBe(true) }) }) diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 3c06dc3718..94987eb3db 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' 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 { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion, } from '@deepseek-ai/dsh-message-feedback/types' diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index 5bfbcb2d1e..d5073d2ab0 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,6 +1,7 @@ /** One Host-generation model catalog shared by every Session selector. */ -import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -22,8 +23,11 @@ export class ModelCatalogDirectory { private generation = 0 private inflight: Promise | undefined - /** @param session - Session Remote namespace carrying the Host-generation catalog. */ - constructor(private readonly session: Pick) {} + /** + * @param ctx - the providing plugin's context, whose `remote.session` + * namespace carries the Host-generation catalog. + */ + constructor(private readonly ctx: ClientContext) {} /** * Return the current generation's catalog, sharing its one in-flight load. @@ -38,7 +42,7 @@ export class ModelCatalogDirectory { draft.status = 'loading' draft.error = null }) - const operation = this.session.modelCatalog().then((response) => { + const operation = this.ctx.remote.session.modelCatalog().then((response) => { if (!response.ok) { throw new Error(`${response.error.code}: ${response.error.message}`) } diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index 2053d70b85..e72cdedbd3 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -48,7 +48,7 @@ export class ModelDirectoryResolver extends Service { constructor(ctx: Context, config: { blockReason: () => string }) { super(ctx, 'modelDirectories') this.blockReason = config.blockReason - this.catalog = new ModelCatalogDirectory(ctx.remote.session) + this.catalog = new ModelCatalogDirectory(ctx) void this.catalog.load().catch(() => { /* selectors expose the shared error */ }) ctx.on('connection/reset', () => { this.catalog.resetGeneration() diff --git a/packages/client/ui-model-selection/tests/catalog.client.spec.ts b/packages/client/ui-model-selection/tests/catalog.client.spec.ts index a9333113e8..f800266de2 100644 --- a/packages/client/ui-model-selection/tests/catalog.client.spec.ts +++ b/packages/client/ui-model-selection/tests/catalog.client.spec.ts @@ -1,4 +1,5 @@ -import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it, vi } from 'vitest' import { ModelCatalogDirectory } from '../src/client/catalog.ts' @@ -10,22 +11,23 @@ const catalog = (model: string): ModelCatalog => ({ }) function directory(models: () => Promise): ModelCatalogDirectory { - return new ModelCatalogDirectory({ modelCatalog: models } as unknown as ClientRemote['session']) + // The providing plugin's context, scripted down to the one method it calls. + return new ModelCatalogDirectory({ remote: { session: { modelCatalog: models } } } as never) } describe('ModelCatalogDirectory', () => { it('shares one failing request, exposes the RPC error, and permits a retry', async () => { const models = vi.fn() .mockResolvedValueOnce({ - ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'catalog offline', {}), }) .mockResolvedValueOnce({ ok: true, value: catalog('recovered') }) const subject = directory(models) const first = subject.load() expect(subject.load()).toBe(first) - await expect(first).rejects.toThrow('unavailable: catalog offline') - expect(subject.store.getSnapshot()).toMatchObject({ status: 'error', error: 'unavailable: catalog offline' }) + await expect(first).rejects.toThrow('gateway/internal: catalog offline') + expect(subject.store.getSnapshot()).toMatchObject({ status: 'error', error: 'gateway/internal: catalog offline' }) await expect(subject.load()).resolves.toEqual(catalog('recovered')) expect(models).toHaveBeenCalledTimes(2) }) 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 a46cdcf212..f30c3f7f3e 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 @@ -179,7 +179,7 @@ describe('ModelSelect reasoning effort', () => { }] const directory = createSnapshotStore(state({ groups })) const select = vi.fn(async () => { - directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' })) + directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' })) return false }) render( { fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })) const toast = await screen.findByRole('alert') - expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images') + expect(toast.textContent).toContain('模型操作失败:session/model-unavailable: session already contains images') // The selection failure does not render the in-menu load strip (no Retry). expect(screen.queryByRole('button', { name: '重试' })).toBeNull() }) diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 3b2a405951..526d10efe6 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -117,7 +117,7 @@ export function apply(ctx: ClientContext): void { // The shared SettingsScope mirror updates after document commits and reconnects. const controller = new PermissionPresetSettingsController( - ctx.settingsScope.describe(), { settings: ctx.remote.settings }, ctx.settingsSchema) + ctx.settingsScope.describe(), ctx, ctx.settingsSchema) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ 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 a139df2a7c..a435273c27 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -6,12 +6,13 @@ * back into the mirror. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' import type { - SchemaNode, SettingsDescribeFace, SettingsSchemaService, SettingsWireFace, + SchemaNode, SettingsDescribeFace, SettingsSchemaService, } from '@deepseek-ai/dsh-client-ui-settings/client' import { displayPermissionPreset } from './presentation.ts' @@ -94,12 +95,13 @@ export class PermissionPresetSettingsController { /** * @param describeFace - the shared mirror's read/fold face (descriptor and schema source). - * @param api - settings wire face for the `defaultPreset` write. + * @param ctx - the row plugin's context, whose `remote.settings` namespace + * carries the `defaultPreset` write. * @param schema - settings-owned schema operations. */ constructor( private readonly describeFace: SettingsDescribeFace, - private readonly api: SettingsWireFace, + private readonly ctx: ClientContext, private readonly schema: SettingsSchemaService, ) {} @@ -136,23 +138,26 @@ export class PermissionPresetSettingsController { draft.status = 'saving' draft.error = null }) + let response try { - const response = await this.api.settings.mutate( + response = await this.ctx.remote.settings.mutate( PERMISSION_SETTINGS_NS, [{ op: 'set', path: ['defaultPreset'], value: preset }], view.revision, ) - if (!response.ok) throw new Error(response.error.message) + } finally { + // Cleared before the fold below, whose publish reaches `derive` through + // this row's own subscription and is skipped while a save is pending. this.saving = false - if (this.disposed) return - // The mirror publish reaches this row's own subscription, so the fold - // is also what republishes the accepted value here. - this.describeFace.acceptView(response.value) - } catch (error) { - this.saving = false - if (this.disposed) return - this.fail(error) } + if (this.disposed) return + if (!response.ok) { + this.fail(response.error) + return + } + // The mirror publish reaches this row's own subscription, so the fold + // is also what republishes the accepted value here. + this.describeFace.acceptView(response.value) } /** Stop following the mirror; later publishes leave the snapshot alone. */ 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 1d43249c75..c3fd611b99 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 @@ -48,17 +48,6 @@ async function bench() { 'settings.general.item': { kind: 'list', scope: 'root' }, }, } as never, () => null) - ctx.provide('connection', { - api: { - settings: { - describe: () => Promise.resolve({ - rpcId: 'describe', - result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }, - }), - mutate: () => Promise.reject(new Error('settings mutation is not exercised')), - }, - }, - } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() let decoration: CommandDecoration | undefined ctx.provide('commandUi', { @@ -81,7 +70,7 @@ async function bench() { commands.push(line) return Promise.resolve(commandResult.ok ? { ok: true as const, value: { matched: commandResult.matched ?? true } } - : { ok: false as const, error: { code: 'internal', message: 'boom' } }) + : { ok: false as const, error: { code: 'gateway/internal', message: 'boom' } }) }, }) ctx.provide('sessions', { 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 de7f6d4411..11e01893a9 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 @@ -2,7 +2,7 @@ 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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' @@ -12,10 +12,10 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store const schema = new SettingsSchemaService(new Context()) -/** Controller over a real mirror derived from the same fake wire. */ -function derivedController(api: { settings: object }) { - const wire = api as never - return new PermissionPresetSettingsController(new SettingsDescribeMirror(wire), wire, schema) +/** Controller over a real mirror derived from the same scripted context. */ +function derivedController(remote: { settings: object }) { + const ctx = { remote } as never + return new PermissionPresetSettingsController(new SettingsDescribeMirror(ctx), ctx, schema) } afterEach(cleanup) @@ -155,7 +155,9 @@ describe('PermissionRow', () => { describe: () => describe.promise, mutate: () => Promise.resolve({ ok: false as const, - error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, + error: new RemoteError('settings/conflict', 'changed elsewhere', { + ns: 'permission', expected: 1, actual: 2, + }), }), }, }) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index b607bcea99..c999d9ee1a 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { PermissionPresetSettingsController, permissionDefaultOf, } from '../src/client/settings-store.ts' @@ -40,11 +41,11 @@ function ok(value: T) { return { ok: true as const, value } } -/** The permission controller over a real mirror and one fake wire. */ +/** The permission controller over a real mirror and one scripted context. */ function permissionController(api: object) { - const wire = { settings: api } as never - const mirror = new SettingsDescribeMirror(wire) - return { mirror, controller: new PermissionPresetSettingsController(mirror, wire, schema) } + const ctx = { remote: { settings: api } } as never + const mirror = new SettingsDescribeMirror(ctx) + return { mirror, controller: new PermissionPresetSettingsController(mirror, ctx, schema) } } describe('permission settings store', () => { @@ -142,7 +143,7 @@ describe('permission settings store', () => { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => Promise.resolve({ ok: false as const, - error: { code: 'settings-conflict', message: 'stale', details: {} }, + error: new RemoteError('settings/conflict', 'stale', { ns: 'permission', expected: 1, actual: 2 }), }), }).controller await failing.load() @@ -170,7 +171,7 @@ describe('permission settings store', () => { const rejected = permissionController({ describe: () => Promise.resolve({ ok: false as const, - error: { code: 'internal', message: 'offline', details: {} }, + error: new RemoteError('gateway/internal', 'offline', {}), }), mutate, }).controller @@ -186,16 +187,18 @@ describe('permission settings store', () => { await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) - const wire = { - settings: { - describe: () => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [view('read-only')], - })), - mutate, + const ctx = { + remote: { + settings: { + describe: () => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [view('read-only')], + })), + mutate, + }, }, } as never - const mirror = new SettingsDescribeMirror(wire) - const malformed = new PermissionPresetSettingsController(mirror, wire, { + const mirror = new SettingsDescribeMirror(ctx) + const malformed = new PermissionPresetSettingsController(mirror, ctx, { rehydrate: () => { throw 'schema disconnected' }, } as never) await malformed.load() @@ -207,9 +210,9 @@ describe('permission settings store', () => { it('hides the row in a remote browser instead of loading forever', async () => { const describeCall = vi.fn() const mutate = vi.fn() - const wire = { settings: { describe: describeCall, mutate } } as never - const mirror = new SettingsDescribeMirror(wire, 'memory') - const controller = new PermissionPresetSettingsController(mirror, wire, schema) + const ctx = { remote: { settings: { describe: describeCall, mutate } } } as never + const mirror = new SettingsDescribeMirror(ctx, 'memory') + const controller = new PermissionPresetSettingsController(mirror, ctx, schema) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') await controller.select('workspace-write') @@ -265,15 +268,20 @@ describe('permission settings store', () => { await saving expect(active.store.getSnapshot().status).toBe('saving') - const rejectedMutation = Promise.withResolvers>>() + const refusedMutation = Promise.withResolvers< + ReturnType> | { ok: false; error: RemoteError } + >() const { controller: disposedWrite } = permissionController({ describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => rejectedMutation.promise, + mutate: () => refusedMutation.promise, }) await disposedWrite.load() const writing = disposedWrite.select('workspace-write') disposedWrite.dispose() - rejectedMutation.reject(new Error('late write')) + refusedMutation.resolve({ + ok: false, + error: new RemoteError('settings/conflict', 'late write', { ns: 'permission', expected: 1, actual: 2 }), + }) await writing expect(disposedWrite.store.getSnapshot().status).toBe('saving') }) diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 9fe26757fa..ee5b790a79 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' 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 { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { PlanChip } from '../src/client/PlanModeControl.tsx' import type { PlanChipInjected } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts' @@ -74,9 +75,9 @@ describe('ui-plan browser apply', () => { // reports the RPC failure in its error branch. b.execute.mockResolvedValueOnce({ ok: false, - error: { code: 'session-not-found', message: 'gone', details: {} }, + error: new RemoteError('session/not-found', 'gone', { sessionId: SID }), } as never) - await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone (session/not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never) 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 3bc2dc9a3f..1249e89732 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 @@ -8,10 +8,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.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 '../src/client/locales.ts' diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 70928a652c..b0b05a0aa1 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 09ecf5aaa9..3728fb7562 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -17,7 +17,6 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' import type { ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource, @@ -30,7 +29,7 @@ import { en, NS, zh, type ReferenceKey } from './locales.ts' /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ - 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ] @@ -41,30 +40,25 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) - const connection = ctx.get('connection') as ConnectionHandle const sessions = ctx.get('sessions') as ISessions const source: InputTriggerSource = { trigger: '@', name: 'reference', showGroupTitle: false, async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) { - const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( - result => result.ok ? result.value : [], - () => [], - ) + const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal) + .then(result => result.ok ? result.value : []) const sessionLookup = quoted === true ? Promise.resolve([] as SessionReferenceMentionCandidate[]) - : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( - result => result.ok ? result.value : [], - () => [], - ) + : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal) + .then(result => result.ok ? result.value : []) const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup]) if (signal.aborted) return [] // The header already names the directory being listed; rows repeat it only // when there is no header to carry it. const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined const now = Date.now() - const home = connection.generation.getSnapshot()?.host.home + const home = ctx.remote.$host.home const listed = sessions.list.getSnapshot().byId return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), 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 4565e170db..c2b729cd69 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -7,6 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -84,6 +85,8 @@ async function bench( }, }) class RemoteService extends Service { + readonly $host = { home: HOME, isLoopback: true } + constructor(serviceCtx: Context) { super(serviceCtx, 'remote') } @@ -92,7 +95,6 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -103,7 +105,7 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ - 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ]) const { fiber } = await bench() @@ -116,6 +118,8 @@ describe('apply', () => { }, }) class RemoteService extends Service { + readonly $host = { home: undefined, isLoopback: false } + constructor(serviceCtx: Context) { super(serviceCtx, 'remote') } @@ -124,7 +128,6 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { generation: { getSnapshot: () => undefined } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() @@ -217,7 +220,10 @@ describe('candidates', () => { ok: true as const, value: [{ path: 'README.md', kind: 'file' as const }], }) - .mockRejectedValueOnce(new Error('file scan failed')) + .mockResolvedValueOnce({ + ok: false as const, + error: new RemoteError('gateway/internal', 'file scan failed', {}), + }) const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [{ @@ -267,18 +273,16 @@ describe('candidates', () => { ok: true as const, value: [{ path: 'bad\nname', kind: 'file' as const }], })) - const sessions = vi.fn() - .mockRejectedValueOnce(new Error('session lookup failed')) - .mockResolvedValueOnce({ - ok: false as const, - error: { code: 'internal', message: 'session lookup failed', details: {} }, - }) + const sessions = vi.fn(() => Promise.resolve({ + ok: false as const, + error: new RemoteError('gateway/internal', 'session lookup failed', {}), + })) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) files.mockResolvedValueOnce({ ok: false as const, - error: { code: 'internal', message: 'file lookup failed', details: {} }, + error: new RemoteError('gateway/internal', 'file lookup failed', {}), } as never) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) }) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 3f4de05dd4..2a21623ef0 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -34,7 +34,6 @@ "inject": [ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -52,7 +51,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", @@ -64,7 +62,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index abdf2829b3..0f12408793 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,8 @@ * Export discipline: packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the ctx.remote merge and its fixed Host facts. +import type {} 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 // merge. Cross-plugin collaboration goes through the service, never a value @@ -55,7 +56,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'] +export const inject = ['slots', 'locale', 'remote', 'remote.settings', 'settingsScope'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -69,10 +70,9 @@ export function apply(ctx: ClientContext): void { // seat, and the nav label is a thunk the owner resolves per render — no // locale/change re-registration wiring. const t = ctx.locale.bind(NS) - const connection = ctx.get('connection') as ConnectionHandle // The shared SettingsScope mirror updates after document commits and reconnects. - const documentController = connection.isLoopback - ? new SettingsDocumentStore(ctx.remote, ctx.settingsScope.describe()) + const documentController = ctx.remote.$host.isLoopback + ? new SettingsDocumentStore(ctx, ctx.settingsScope.describe()) : undefined const documentInjected = documentController === undefined ? undefined 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 4e7fec2ecd..1a5f45d7ee 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,6 +1,8 @@ /** State owner for the optional local settings-document action. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -14,10 +16,6 @@ export interface SettingsDocumentState { error: string | null } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - /** Derives local-document availability from the shared mirror and invokes the pathless Host-owned open operation. */ export class SettingsDocumentStore { /** uSES-safe state source shared by the registered header action. */ @@ -28,11 +26,12 @@ export class SettingsDocumentStore { private following: (() => void) | undefined /** - * @param api - loopback settings wire face that opens the provider document. + * @param ctx - the plugin's context, whose loopback `remote.settings` + * namespace opens the provider document. * @param describeFace - the shared mirror's describe face (`hasDocument` source). */ constructor( - private readonly remote: Pick, + private readonly ctx: ClientContext, private readonly describeFace: SettingsDescribeFace, ) {} @@ -63,10 +62,11 @@ export class SettingsDocumentStore { state.error = null }) try { - const result = await this.remote.settings.openSettingsDocument() - if (!result.ok) throw new Error(result.error.message) - } catch (error) { - this.store.update((state) => { state.error = messageOf(error) }) + const result = await this.ctx.remote.settings.openSettingsDocument() + if (!result.ok) { + const { message } = result.error + this.store.update((state) => { state.error = message }) + } } finally { this.store.update((state) => { state.opening = false }) } 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 86ec40db91..5b9c4d04ed 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -42,12 +42,11 @@ async function bench(isLoopback = true) { const settingsOpenDocument = vi.fn(() => Promise.resolve({ ok: true as const, value: { opened: true as const }, })) - ctx.provide('connection', { - isLoopback, - } as never) - new TestRemote(ctx, { + const remote = new TestRemote(ctx, { settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, }) + // The fixed Host facts the shell reads its loopback-only action from. + remote.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -76,7 +75,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.settings', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { 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 19464e6778..d106f0211e 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' @@ -10,10 +10,10 @@ import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' -/** Store over a real mirror derived from the same fake wire. */ -function derivedDocumentStore(api: object) { - const wire = api as never - return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) +/** Store over a real mirror derived from the same scripted context. */ +function derivedDocumentStore(remote: object) { + const ctx = { remote } as never + return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } import { en } from '../src/client/locales.ts' @@ -97,9 +97,9 @@ describe('SettingsDocumentAction', () => { const describe = vi.fn() .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }) .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }) - const wire = { settings: { describe, openSettingsDocument: vi.fn() } } as never - const mirror = new SettingsDescribeMirror(wire) - const controller = new SettingsDocumentStore(wire, mirror) + const ctx = { remote: { settings: { describe, openSettingsDocument: vi.fn() } } } as never + const mirror = new SettingsDescribeMirror(ctx) + const controller = new SettingsDocumentStore(ctx, mirror) const first = render( { })), openSettingsDocument: vi.fn(() => Promise.resolve({ ok: false as const, - error: { code: 'internal' as const, message: 'xdg-open missing', details: {} }, + error: new RemoteError('gateway/internal', 'xdg-open missing', {}), })), }, }) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index b166e8b452..6bcc3d662e 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' -/** Store over a real mirror derived from the same fake wire. */ -function derivedDocumentStore(api: object) { - const wire = api as never - return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) +/** Store over a real mirror derived from the same scripted context. */ +function derivedDocumentStore(remote: object) { + const ctx = { remote } as never + return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } function response(hasDocument = false) { @@ -18,7 +19,7 @@ function opened(): RemoteResult<{ opened: true }> { } function describeFailed(message: string) { - return { ok: false as const, error: { code: 'internal', message, details: {} } } + return { ok: false as const, error: new RemoteError('gateway/internal', message, {}) } } describe('SettingsDocumentStore', () => { @@ -69,42 +70,28 @@ describe('SettingsDocumentStore', () => { const first = controller.open() const second = controller.open() expect(openDocument).toHaveBeenCalledOnce() - resolveOpen({ ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }) + resolveOpen({ ok: false, error: new RemoteError('gateway/internal', 'no default editor', {}) }) await Promise.all([first, second]) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', opening: false, error: 'no default editor', }) }) - it('reports non-Error native failures and recovers availability via a mirror refresh', async () => { - let rejectOpen!: (reason?: unknown) => void - const controller = derivedDocumentStore({ - settings: { - describe: vi.fn(() => Promise.resolve(response(true))), - openSettingsDocument: () => new Promise((_, reject) => { rejectOpen = reject }), - }, - }) - await controller.load() - expect(controller.store.getSnapshot().status).toBe('ready') - const opening = controller.open() - rejectOpen('native unavailable') - await opening - expect(controller.store.getSnapshot()).toMatchObject({ - status: 'ready', opening: false, error: 'native unavailable', - }) - + it('recovers availability via a mirror refresh after a failed first read', async () => { // A first read that failed leaves the action unavailable with the miss // recorded; the mirror's next refresh (a commit or reconnect) recovers it. - const wire = { - settings: { - describe: vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(response(true)), - openSettingsDocument: vi.fn(), + const ctx = { + remote: { + settings: { + describe: vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(response(true)), + openSettingsDocument: vi.fn(), + }, }, } as never - const mirror = new SettingsDescribeMirror(wire) - const caught = new SettingsDocumentStore(wire, mirror) + const mirror = new SettingsDescribeMirror(ctx) + const caught = new SettingsDocumentStore(ctx, mirror) await caught.load() expect(caught.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) await mirror.load() 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 4bd9263dec..9dbb7ebafe 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -2,6 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' 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' @@ -18,13 +19,16 @@ async function bench() { getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), subscribe: () => () => {}, } as never) - ctx.provide('connection', { api: {}, isLoopback: false } as never) // The shell mounts ui-settings, which injects `remote.settings`; without the // namespace provided its fiber parks and no slot is ever declared. const settings = { - describe: async () => ({ ok: false, error: { code: 'internal', message: 'no settings', details: {} } }), + describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), } - ctx.provide('remote', { $on: () => () => {}, settings } as never) + ctx.provide('remote', { + $on: () => () => {}, + $host: { home: undefined, isLoopback: false }, + settings, + } as never) ctx.provide('remote.settings', settings as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry } @@ -55,7 +59,7 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.settings', 'settingsScope', ]) }) diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 024173af2b..de1c20b8bf 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -23,14 +23,14 @@ import { useState } from 'react' import type { ReactNode } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' -import { deriveKeyRef, messageOf } from './store.ts' -import type { ModelsWire } from './store.ts' +import { deriveKeyRef } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -59,8 +59,8 @@ export interface CustomProviderCardProps { * than a silent overwrite of its whole profile. */ revision: number - /** Wire faces for the write and for interrogating the endpoint. */ - api: ModelsWire + /** The page plugin's context, whose Remote namespaces carry the write and the endpoint interrogation. */ + ctx: ClientContext /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -75,7 +75,7 @@ export interface CustomProviderCardProps { * @returns the creation card. */ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { - const { taken, protocols, api, t } = props + const { taken, protocols, ctx, t } = props // The write is checked against the revision on which this draft was opened. const [openedAt] = useState(() => props.revision) const [route, setRoute] = useState('') @@ -147,12 +147,16 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // `taken` is a snapshot too, so the id check alone cannot see a route // declared after this card opened; the revision makes that race a // `settings-conflict` instead of a write over the other profile. - const response = await api.settings.mutate( + const response = await ctx.remote.settings.mutate( NS, [{ op: 'set', path: ['providers', route], value: profile as JsonValue }], openedAt, ) - if (!response.ok) return response.error.message + if (!response.ok) { + return response.error.code === 'settings/conflict' + ? t('conflict') + : response.error.message + } // The provider now exists. A retry after the key write below fails must // not re-run this mutate: the revision it holds is the one this write // just superseded, so the Host would answer `settings-conflict` and the @@ -160,7 +164,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { setCommitted(true) } if (storesKey) { - const stored = await api.credentials.set(keyRef, keyValue) + const stored = await ctx.remote.credentials.set(keyRef, keyValue) // The profile landed; saying the key did not is the only honest report, // and the retry above now goes straight back to this write. if (!stored.ok) return stored.error.message @@ -178,10 +182,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { return } props.onClose(true) - } catch (error) { - // A transport failure rejects rather than answering; without this the - // card would stay busy with nothing shown. - setFailure(messageOf(error)) } finally { setBusy(false) } @@ -274,7 +274,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} - api={api} + ctx={ctx} t={t} disabled={profileDisabled} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index c27f5379a8..546f89e4a0 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -8,9 +8,10 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { ModelsSettingsState, ModelsSettingsStore, ModelsWire } from './store.ts' +import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { onboardingReadiness } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor } from './ProviderEditor.tsx' @@ -26,8 +27,8 @@ export interface DeepSeekOnboardingInjected { } /** Shared Models-page join controller. */ controller: ModelsSettingsStore - /** Existing wire face reused by the Models credential editor. */ - api: ModelsWire + /** The plugin context the reused Models credential editor writes through. */ + ctx: ClientContext /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ @@ -50,7 +51,7 @@ function assertNever(_value: never): never { * @returns the onboarding modal or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { - const { complete, controller, useModels, api, schema, t } = props + const { complete, controller, useModels, ctx, schema, t } = props const state = useModels(snapshot => snapshot) const readiness = onboardingReadiness(state) @@ -105,7 +106,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): namespace={namespace} schema={schema} settingsPath={row.entry.settingsPath} - api={api} + ctx={ctx} t={t} readOnly={false} hideTitle diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index a1be9f9085..69714486c2 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,11 +16,11 @@ import { useState } from 'react' import type { ReactNode } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' -import { messageOf, type ModelsWire } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -79,8 +79,8 @@ export interface ModelListEditorProps { * told what the field already says. */ probeBlocked?: keyof typeof en | undefined - /** Wire face the fetch action calls. */ - api: Pick + /** The page plugin's context, whose `remote.llm` namespace answers the fetch action. */ + ctx: ClientContext /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ @@ -157,7 +157,7 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft { * @returns the model-list editor. */ export function ModelListEditor(props: ModelListEditorProps): ReactNode { - const { models, onChange, probe, api, t, disabled } = props + const { models, onChange, probe, ctx, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) const [candidates, setCandidates] = useState(undefined) @@ -229,7 +229,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await api.llm.discoverModels(probe.settingsNs, { + const response = await ctx.remote.llm.discoverModels(probe.settingsNs, { ...probe.provider === undefined ? {} : { provider: probe.provider }, ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL }, ...probe.api === undefined ? {} : { api: probe.api }, @@ -249,10 +249,6 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const known = new Set(models.map(model => textOf(model, 'id'))) setCandidates(found) setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id))) - } catch (error) { - // The transport rejected rather than answering; without this the button - // would stay busy with nothing shown. - setFailure(messageOf(error)) } finally { setBusy(false) } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 7b884ac906..4611067f3e 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -14,13 +14,14 @@ import { useState } from 'react' import type { ReactNode } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls this package's SlotMap merge (the two Models child slots). import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' -import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' -import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' +import { deriveKeyRef, protocolChoices, providerUsable } from './store.ts' +import type { ModelsSettingsStore, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -34,8 +35,8 @@ export interface ModelsSectionInjected { /** Page snapshot bound by the UI renderer as useSnapshot. */ snapshot: ModelsSettingsStore['store'] } - /** Wire faces the editor writes through. */ - api: ModelsWire + /** The page plugin's context, whose Remote namespaces the editors write through. */ + ctx: ClientContext /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Section copy. */ @@ -80,7 +81,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'api' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'ctx' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -104,32 +105,26 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * and the whole operation safely retryable; both unsets are idempotent. * The settings removal names the profile rather than rebuilding its whole * namespace from a partial view. - * @param api - settings and credential wire faces. + * @param ctx - the page plugin's context, carrying the settings and credential Remote namespaces. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - api: Pick, + ctx: ClientContext, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { - try { - if (target.credentialRef !== undefined) { - const credential = await api.credentials.unset(target.credentialRef) - if (!credential.ok) return credential.error.message - } - const response = await api.settings.mutate( - target.settingsNs, - [{ op: 'unset', path: [...target.settingsPath] }], - undefined, - ) - if (!response.ok) return response.error.message - } catch (error) { - // The transport rejected rather than answering; the caller must be able - // to retry the idempotent operation instead of the row silently staying. - return messageOf(error) + if (target.credentialRef !== undefined) { + const credential = await ctx.remote.credentials.unset(target.credentialRef) + if (!credential.ok) return credential.error.message } + const response = await ctx.remote.settings.mutate( + target.settingsNs, + [{ op: 'unset', path: [...target.settingsPath] }], + undefined, + ) + if (!response.ok) return response.error.message await controller.load() return undefined } @@ -198,16 +193,16 @@ export function providerCopy(template: string, target: ProviderIdentity): string * @returns the section, or null while the shell has not injected yet. */ export function ModelsSection(props: ModelsSectionProps): ReactNode { - const { controller, useSnapshot, api, schema, t, renderSlot } = props + const { controller, useSnapshot, ctx, schema, t, renderSlot } = props if ( - controller === undefined || useSnapshot === undefined || api === undefined + controller === undefined || useSnapshot === undefined || ctx === undefined || schema === undefined || t === undefined ) return null - return + return } function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { - const { controller, api, schema, t } = injected + const { controller, ctx, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) @@ -255,7 +250,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (deleteTarget === undefined || deleting) return setDeleting(true) setDeleteFailure(undefined) - void removeProviderProfile(api, controller, deleteTarget) + void removeProviderProfile(ctx, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { setDeleteFailure(failure) @@ -336,7 +331,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - api, + ctx, t, readOnly: !state.writable, onClose: (changed) => { closeSetup(changed, target) }, @@ -431,7 +426,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - api, + ctx, t, readOnly: !state.writable, onClose: (changed) => { closeEditor(changed, target) }, @@ -471,7 +466,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS namespace={addNamespace} schema={schema} settingsPath={addTarget.settingsPath} - api={api} + ctx={ctx} t={t} readOnly={!state.writable} onClose={(changed) => { closeEditor(changed, addTarget) }} @@ -493,7 +488,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS protocols={protocols} /* v8 ignore next -- the card only opens from a button disabled without this namespace */ revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0} - api={api} + ctx={ctx} t={t} readOnly={!state.writable} onClose={(changed) => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 675c852125..3c4cea5aab 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -23,6 +23,7 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -32,8 +33,7 @@ import { import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' -import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' -import type { ModelsWire } from './store.ts' +import { deriveKeyRef, protocolChoices } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -66,8 +66,8 @@ export interface ProviderEditorProps { schema: SettingsSchemaOperations /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] - /** Wire faces for writes and for interrogating a provider endpoint. */ - api: ModelsWire + /** The page plugin's context, whose Remote namespaces carry the writes and the endpoint interrogation. */ + ctx: ClientContext /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -155,7 +155,7 @@ function refFor( * @returns the editor card. */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { - const { namespace, schema, settingsPath, api, t } = props + const { namespace, schema, settingsPath, ctx, t } = props const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) @@ -186,19 +186,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { useEffect(() => { let stale = false setKeyState(undefined) - // The key state is a placeholder hint, not a precondition for editing: - // neither a business rejection nor a transport failure may reach the - // browser as an unhandled rejection, so the card simply renders without - // the "already configured" hint. - void api.credentials.describe([keyRef]).then( - (response) => { - if (stale || !response.ok) return - setKeyState(response.value[keyRef]) - }, - () => undefined, - ) + // The key state is a placeholder hint, not a precondition for editing: a + // refused describe leaves the card without the "already configured" hint. + void ctx.remote.credentials.describe([keyRef]).then((response) => { + if (stale || !response.ok) return + setKeyState(response.value[keyRef]) + }) return () => { stale = true } - }, [api.credentials, keyRef]) + }, [ctx, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -282,9 +277,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? [{ op: 'set', path: [...settingsPath], value: {} }] : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await api.settings.mutate(ns, ops, expectedRevision) + const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) if (!response.ok) { - return response.error.code === 'settings-conflict' + return response.error.code === 'settings/conflict' ? t('conflict') : response.error.message } @@ -293,7 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setDraft(next) } if (keyValue.length > 0) { - const stored = await api.credentials.set(keyRef, keyValue) + const stored = await ctx.remote.credentials.set(keyRef, keyValue) if (!stored.ok) return stored.error.message } setKeyDraft('') @@ -310,11 +305,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { return } props.onClose(true) - } catch (error) { - // A transport failure (disconnect, a request the host refuses) rejects - // rather than answering; without this the card would stay busy forever - // with no error shown. - setFailure(messageOf(error)) } finally { setBusy(false) } @@ -474,7 +464,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : } + : }

} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index c397d795c9..8cb361ead6 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -23,7 +23,6 @@ import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' -import type { ModelsWire } from './store.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -42,7 +41,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'settings.models' export type { - ModelsCredentials, ModelsLlm, ModelsSettingsState, ModelsWire, ProviderDirectoryEntry, ProviderRow, + ModelsSettingsState, ProviderDirectoryEntry, ProviderRow, } from './store.ts' /** @@ -75,27 +74,21 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const schema = createSettingsSchemaOperations(ctx.settingsSchema) - // Every configuration operation rides its owning Remote namespace. - const wire: ModelsWire = { - credentials: ctx.remote.credentials, - llm: ctx.remote.llm, - settings: ctx.remote.settings, - } - const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe()) + const controller = new ModelsSettingsStore(ctx, schema, ctx.settingsScope.describe()) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. const t = ctx.locale.bind(NS) as ModelsSectionInjected['t'] const injected = (): ModelsSectionInjected => ({ controller, hooks: { snapshot: controller.store }, - api: wire, + ctx, schema, t, }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ controller, hooks: { models: controller.store }, - api: wire, + ctx, schema, t, }) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index fe98870321..fd8eeaf9f9 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -7,12 +7,13 @@ * re-renders from the next describe, pushed or refetched. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { - ClientRemote, CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, + CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsDescribeFace, SettingsRemote } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SettingsSchemaOperations } from './schema-operations.ts' /** @@ -21,15 +22,6 @@ import type { SettingsSchemaOperations } from './schema-operations.ts' */ const PROBE_ROUTE = '\u0000probe' -/** The credentials Remote methods the Models page reads and writes through. */ -export type ModelsCredentials = Pick - -/** LLM Remote methods used by the Models page. */ -export type ModelsLlm = Pick< - ClientRemote['llm'], - 'discoverModels' | 'listConfigurableProviders' | 'listProviders' -> - /** One provider row after joining the configurable directory with live routes. */ export interface ProviderDirectoryEntry { readonly provider: string @@ -73,18 +65,6 @@ export function joinProviderDirectory( return rows } -/** - * Every Remote wire face the Models page reaches. - */ -export interface ModelsWire { - /** The settings Remote namespace: the redacted read and the profile writes. */ - settings: SettingsRemote - /** Credential state and writes for the references provider profiles name. */ - credentials: ModelsCredentials - /** Provider directory reads and draft endpoint discovery. */ - llm: ModelsLlm -} - /** One provider row the page renders. */ export interface ProviderRow { /** The directory entry (route id, display name, settings address, live state). */ @@ -121,17 +101,6 @@ export interface ModelsSettingsState { namespaces: ReadonlyMap } -/** - * Human text for a rejected wire call. A transport failure rejects with an - * Error; a host or a runtime can reject with anything, and the page still has - * to say something. - * @param error - the rejection value. - * @returns the message to show. - */ -export function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - /** * Derive the conventional credential reference for a provider route: the v1 * page never asks for an environment-variable name, so a typed key stores @@ -187,11 +156,13 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the page's credentials Remote and LLM wire faces. + * @param ctx - the page plugin's context, whose `remote.llm` and + * `remote.credentials` namespaces carry the directory and credential reads. + * @param schema - settings-owned schema and immutable path operations. * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: Pick, + private readonly ctx: ClientContext, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} @@ -207,32 +178,21 @@ export class ModelsSettingsStore { async load(): Promise { const generation = ++this.generation this.store.update((s) => { s.status = 'loading'; s.error = null }) - let providers: ProviderDirectoryEntry[] - let writable: boolean - let views: readonly SettingsNamespaceView[] - try { - const [registered, declared] = await Promise.all([ - this.api.llm.listProviders(), - this.api.llm.listConfigurableProviders(), - this.describeFace.ensure(), - ]) - if (!registered.ok) throw new Error(registered.error.message) - if (!declared.ok) throw new Error(declared.error.message) - const mirrored = this.describeFace.getSnapshot() - if (mirrored.view === undefined) { - throw new Error(mirrored.error ?? 'settings are unavailable in this browser') - } - providers = joinProviderDirectory(registered.value, declared.value) - writable = mirrored.view.writable - views = mirrored.view.namespaces - } catch (error) { - if (generation !== this.generation) return - this.store.update((s) => { - s.status = 'error' - s.error = error instanceof Error ? error.message : String(error) - }) + const [registered, declared] = await Promise.all([ + this.ctx.remote.llm.listProviders(), + this.ctx.remote.llm.listConfigurableProviders(), + this.describeFace.ensure(), + ]) + if (!registered.ok) { this.failLoad(generation, registered.error.message); return } + if (!declared.ok) { this.failLoad(generation, declared.error.message); return } + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + this.failLoad(generation, mirrored.error ?? 'settings are unavailable in this browser') return } + const providers = joinProviderDirectory(registered.value, declared.value) + const writable = mirrored.view.writable + const views: readonly SettingsNamespaceView[] = mirrored.view.namespaces const namespaces = new Map(views.map(view => [view.ns, view])) const rows: ProviderRow[] = providers.map((entry) => { const namespace = namespaces.get(entry.settingsNs) @@ -254,16 +214,12 @@ export class ModelsSettingsStore { let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { - try { - const response = await this.api.credentials.describe(refs) - // Credential state is an enrichment for the Models page: neither a - // business rejection nor a transport failure fails the load. The - // onboarding projection below retains the failure distinction. - if (response.ok) credentials = response.value - else credentialError = response.error.message - } catch (error) { - credentialError = messageOf(error) - } + const response = await this.ctx.remote.credentials.describe(refs) + // Credential state is an enrichment for the Models page: a failure + // degrades the badge instead of failing the load. The onboarding + // projection below retains the failure distinction. + if (response.ok) credentials = response.value + else credentialError = response.error.message } if (generation !== this.generation) return this.store.update((s) => { @@ -283,6 +239,15 @@ export class ModelsSettingsStore { s.namespaces = namespaces }) } + + /** Publish one load's failure text, unless a newer load already took over. */ + private failLoad(generation: number, message: string): void { + if (generation !== this.generation) return + this.store.update((s) => { + s.status = 'error' + s.error = message + }) + } } /** 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 4513bde5a2..1d42d47682 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -41,7 +41,8 @@ async function bench(isLoopback = true, settings?: object, services: object = {} // ui-settings apply also provides the settingsSchema service. settings: settings ?? scriptedSettingsRemote().settings, }) - ctx.provide('connection', { api: services, isLoopback } as never) + // The fixed Host facts the settings provider reads its persistence from. + remote.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote } } @@ -84,7 +85,7 @@ describe('ui-settings-models apply', () => { expect(injected.t('deleteTitle')).toBe('删除 {provider}?') expect(typeof injected.controller.load).toBe('function') expect(injected.hooks.snapshot).toBe(injected.controller.store) - expect(injected.api).toBeDefined() + expect(injected.ctx).toBeDefined() const onboarding = before.slots.entries('settings.onboarding') expect(onboarding).toHaveLength(2) expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({ @@ -98,7 +99,7 @@ describe('ui-settings-models apply', () => { deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected )() expect(deepSeekInjected.hooks.models).toBe(injected.controller.store) - expect(deepSeekInjected.api).toBeDefined() + expect(deepSeekInjected.ctx).toBeDefined() const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() 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 a22f787a1b..361e080e32 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -3,8 +3,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { + CredentialInfo, JsonValue, RemoteResult, SettingsNamespaceView, +} from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' @@ -137,8 +139,19 @@ function wireNamespaces(): SettingsNamespaceView[] { function remoteOk(value: T) { return { ok: true as const, value } } -function remoteFail(message: string, code = 'credential-rejected') { - return { ok: false as const, error: { code, message, details: {} } } +/** The codes this page's scripted Host answers refuse with. */ +type RefusalCode = 'credential/rejected' | 'gateway/internal' | 'settings/conflict' | 'settings/rejected' + +/** One refusal per code, each carrying the details its own code declares. */ +const REFUSALS: { [Code in RefusalCode]: (message: string) => RemoteError } = { + 'credential/rejected': message => new RemoteError('credential/rejected', message, { ref: 'DEEPSEEK_API_KEY' }), + 'gateway/internal': message => new RemoteError('gateway/internal', message, {}), + 'settings/conflict': message => + new RemoteError('settings/conflict', message, { ns: 'llm-pi-ai', expected: 4, actual: 5 }), + 'settings/rejected': message => new RemoteError('settings/rejected', message, { ns: 'llm-pi-ai' }), +} +function remoteFail(message: string, code: RefusalCode = 'credential/rejected') { + return { ok: false as const, error: REFUSALS[code](message) } } function scriptedFace(overrides: { @@ -174,13 +187,16 @@ function scriptedFace(overrides: { mutate, }, credentials: { - describe: vi.fn((refs: string[]) => Promise.resolve(remoteOk( - Object.fromEntries(refs.map(ref => [ref, { - configured: ref === 'OPENAI_API_KEY', - ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {}, - writable: true, - }])), - ))), + // Typed as the Remote answer rather than the success branch alone: a + // case that scripts a refusal replaces this mock. + describe: vi.fn((refs: string[]): Promise>> => + Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { + configured: ref === 'OPENAI_API_KEY', + ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {}, + writable: true, + }])), + ))), set, unset, }, @@ -188,7 +204,21 @@ function scriptedFace(overrides: { return { face, update, mutate, set, unset } } -type WireFace = ConstructorParameters[0] +type PageContext = ConstructorParameters[0] + +/** + * The page plugin's context, scripted down to the namespaces the page reaches. + * One context per face, as in production: an editor effect keyed by the context + * would otherwise re-probe on every render. + */ +const contexts = new WeakMap() +function ctxWith(face: object): PageContext { + const existing = contexts.get(face) + if (existing !== undefined) return existing + const ctx = { remote: face } as unknown as PageContext + contexts.set(face, ctx) + return ctx +} /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -214,20 +244,21 @@ function cardSeatCalls( async function mountFace(scripted: ReturnType) { const { face, update, mutate, set, unset } = scripted - const mirror = new SettingsDescribeMirror(face as never) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror) + const ctx = ctxWith(face) + const mirror = new SettingsDescribeMirror(ctx) + const controller = new ModelsSettingsStore(ctx, settingsSchema, mirror) await controller.load() const renderSlot = stubRenderSlot() const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - api: face as never, + ctx, schema: settingsSchema, t, renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], } const view = render() - return { view, face, update, mutate, set, unset, controller, mirror, renderSlot } + return { view, ctx, face, update, mutate, set, unset, controller, mirror, renderSlot } } async function mountSection(overrides: Parameters[0] = {}) { @@ -351,12 +382,12 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), ))) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) await controller.load() render( null} @@ -375,13 +406,13 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), ))) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) await controller.load() cleanup() render( null} @@ -463,7 +494,7 @@ describe('ModelsSection', () => { namespace={wireNamespaces()[0]!} schema={settingsSchema} settingsPath={[]} - api={face as never} + ctx={ctxWith(face)} t={t} readOnly={false} credentialOnly @@ -716,7 +747,7 @@ describe('ModelsSection', () => { namespace={overridden} schema={settingsSchema} settingsPath={[]} - api={face as never} + ctx={ctxWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -946,7 +977,7 @@ describe('ModelsSection', () => { namespace={bare} schema={settingsSchema} settingsPath={[]} - api={face as never} + ctx={ctxWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -1089,7 +1120,7 @@ describe('ModelsSection', () => { it('surfaces a rejected settings write and never stores the key after it', async () => { const { set } = await mountSection({ - mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings-rejected'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings/rejected'))), }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) @@ -1099,38 +1130,28 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('renders the card without the stored-key hint when the credential probe rejects', async () => { - // The probe is a placeholder hint, not a precondition: an escaping - // rejection would surface in the browser as an unhandled rejection. + it('renders the card without the stored-key hint when the credential probe is refused', async () => { const { face } = scriptedFace() - face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost'))) - const unhandled = vi.fn() - process.on('unhandledRejection', unhandled) - try { - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) - await controller.load() - render( null} - />) - const key = await screen.findByLabelText(en.keyInput) - expect(key.placeholder).toBe(en.keyPlaceholder) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(unhandled).not.toHaveBeenCalled() - } finally { - process.off('unhandledRejection', unhandled) - } + face.credentials.describe = vi.fn(() => Promise.resolve(remoteFail('no credential provider'))) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) + await controller.load() + render( null} + />) + const key = await screen.findByLabelText(en.keyInput) + expect(key.placeholder).toBe(en.keyPlaceholder) }) it('tells the user to reopen when another writer moved the namespace first', async () => { // The stale-draft overwrite: two tabs open the same card, the other saves, // and this one must be refused rather than replay its opening snapshot. const { set } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings-conflict'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))), }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://mine' } }) @@ -1139,15 +1160,14 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('keeps the card usable when the write rejects instead of answering', async () => { - // A transport failure (disconnect, or the 403 a non-loopback browser now - // gets on the whole configuration plane) rejects rather than returning a - // failed envelope: without a catch the card would stay busy forever. - await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) + it('keeps the card usable after a refused write', async () => { + await mountDeepSeekCard({ + mutate: vi.fn(() => Promise.resolve(remoteFail('the host refused', 'settings/rejected'))), + }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://next' } }) fireEvent.click(screen.getByText(en.apply)) - await screen.findByText('connection lost') + await screen.findByText('the host refused') // Not stuck in `applying…`: the finally cleared busy, so Apply is live again. expect(screen.getByText(en.apply)).toBeTruthy() }) @@ -1178,7 +1198,7 @@ describe('ModelsSection', () => { it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() - face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'internal')) as never) + face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal'))) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) const editorKey = await screen.findByLabelText(en.keyInput) expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) @@ -1245,14 +1265,14 @@ describe('ModelsSection', () => { it('renders the load failure with a retry control', async () => { const face = scriptedFace() - face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'internal'))) as never + face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'gateway/internal'))) as never const controller = new ModelsSettingsStore( - face.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face.face as never)) + ctxWith(face.face), settingsSchema, new SettingsDescribeMirror(ctxWith(face.face))) await controller.load() render( null} @@ -1269,13 +1289,13 @@ describe('ModelsSection', () => { hasDocument: false, namespaces: wireNamespaces(), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) await controller.load() cleanup() render( null} @@ -1333,11 +1353,11 @@ describe('ModelsSection', () => { it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) render( null} @@ -1350,7 +1370,7 @@ describe('ModelsSection', () => { // would widen the write for no benefit. const { face, mutate, controller } = await mountSection() await removeProviderProfile( - face as unknown as Parameters[0], + ctxWith(face), controller, { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] }, ) @@ -1363,11 +1383,11 @@ describe('ModelsSection', () => { it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => { const { face, controller } = await mountSection({ - mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings-rejected'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings/rejected'))), }) const before = controller.store.getSnapshot().rows const failure = await removeProviderProfile( - face as unknown as Parameters[0], + ctxWith(face), controller, { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, ) @@ -1377,7 +1397,7 @@ describe('ModelsSection', () => { it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => { const mutate = vi.fn() - .mockResolvedValueOnce(remoteFail('the host refused', 'settings-rejected')) + .mockResolvedValueOnce(remoteFail('the host refused', 'settings/rejected')) .mockResolvedValueOnce(remoteOk(wireNamespaces()[2]!)) const { unset } = await mountSection({ mutate }) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) @@ -1418,7 +1438,7 @@ describe('ModelsSection', () => { unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))), }) const failure = await removeProviderProfile( - face as unknown as Parameters[0], + ctxWith(face), controller, { settingsNs: 'llm-pi-ai', @@ -1430,17 +1450,6 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) - it('reports a transport rejection instead of failing the removal silently', async () => { - const { face, controller } = await mountSection({ - mutate: vi.fn(() => Promise.reject(new Error('connection lost'))), - }) - const failure = await removeProviderProfile( - face as unknown as Parameters[0], - controller, - { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, - ) - expect(failure).toBe('connection lost') - }) }) describe('apiKeyFailure', () => { 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 54b7bdd815..92d57eb776 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 @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -22,7 +22,7 @@ function remoteOk(value: T) { return { ok: true as const, value } } function remoteFail(message: string) { - return { ok: false as const, error: { code: 'internal', message, details: {} } } + return { ok: false as const, error: new RemoteError('gateway/internal', message, {}) } } const DeepSeekConfig = Schema.object({ @@ -66,9 +66,8 @@ function harness(options: { credential?: { source?: string; writable: boolean } describeFailure?: string settingsWritable?: boolean - providersReject?: boolean + providersFailure?: string setFailure?: string - setReject?: string } = {}) { if (document.getElementById('root') === null) { const appRoot = document.createElement('div') @@ -80,7 +79,6 @@ function harness(options: { const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv const mutate = vi.fn(() => Promise.resolve(remoteOk(deepSeekNamespace(apiKeyEnv)))) const set = vi.fn((_ref: string, _value: string) => { - if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject)) if (options.setFailure !== undefined) return Promise.resolve(remoteFail(options.setFailure)) fileConfigured = true return Promise.resolve(remoteOk(undefined)) @@ -88,7 +86,7 @@ function harness(options: { const face = { llm: { listProviders: () => { - if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable')) + if (options.providersFailure !== undefined) return Promise.resolve(remoteFail(options.providersFailure)) return Promise.resolve(remoteOk( options.provider === false || options.providerActive === false ? [] @@ -130,7 +128,9 @@ function harness(options: { set, }, } - const controller = new ModelsSettingsStore(face as never, settingsSchema, new SettingsDescribeMirror(face as never)) + // The page plugin's context, scripted down to the namespaces it reaches. + const ctx = { remote: face } as never + const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never @@ -143,7 +143,7 @@ function harness(options: { useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), - api: face as never, + ctx, schema: settingsSchema, t: key => en[key], } @@ -200,10 +200,9 @@ describe('DeepSeekOnboardingDialog', () => { expect(h.set).not.toHaveBeenCalled() }) - it('keeps the modal open and reports rejected and failed credential writes', async () => { + it('keeps the modal open and reports a refused credential write', async () => { for (const [options, message] of [ [{ setFailure: 'credential was rejected' }, 'credential was rejected'], - [{ setReject: 'connection lost' }, 'connection lost'], ] as const) { const h = harness(options) const view = render() @@ -235,7 +234,7 @@ describe('DeepSeekOnboardingDialog', () => { harness({ describeFailure: 'credentials service is absent' }), harness({ credential: { writable: false } }), harness({ settingsWritable: false }), - harness({ providersReject: true }), + harness({ providersFailure: 'the provider directory is unavailable' }), harness({ providerActive: false }), harness({ settingsNamespace: false }), harness({ apiKeyEnv: null }), diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 0bf6b59861..96e39a8bfc 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' @@ -41,15 +41,33 @@ const PiAiConfig = Schema.object({ function ok(value: T) { return { ok: true as const, value } } -function fail(message: string, code: string) { - return { ok: false as const, error: { code, message, details: {} } } +/** One draft-interrogation failure per code, each carrying its own details. */ +const DISCOVERY_FAILURES: { + [Code in 'gateway/internal' | 'llm/model-discovery-rejected']: (message: string) => RemoteError +} = { + 'gateway/internal': message => new RemoteError('gateway/internal', message, {}), + 'llm/model-discovery-rejected': message => + new RemoteError('llm/model-discovery-rejected', message, { settingsNs: 'llm-pi-ai' }), +} +function fail(message: string, code: keyof typeof DISCOVERY_FAILURES) { + return { ok: false as const, error: DISCOVERY_FAILURES[code](message) } } /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } } -function remoteFail(message: string, code = 'credential-rejected') { - return { ok: false as const, error: { code, message, details: {} } } +/** The codes this page's scripted Host answers refuse with. */ +type RefusalCode = 'credential/rejected' | 'settings/conflict' | 'settings/rejected' + +/** One refusal per code, each carrying the details its own code declares. */ +const REFUSALS: { [Code in RefusalCode]: (message: string) => RemoteError } = { + 'credential/rejected': message => new RemoteError('credential/rejected', message, { ref: 'OPENAI_API_KEY' }), + 'settings/conflict': message => + new RemoteError('settings/conflict', message, { ns: 'llm-pi-ai', expected: 7, actual: 8 }), + 'settings/rejected': message => new RemoteError('settings/rejected', message, { ns: 'llm-pi-ai' }), +} +function remoteFail(message: string, code: RefusalCode = 'credential/rejected') { + return { ok: false as const, error: REFUSALS[code](message) } } function piAiNamespace( @@ -121,7 +139,21 @@ function scriptedFace(options: { return { face, discover, mutate, set, namespace } } -type WireFace = ConstructorParameters[0] +type PageContext = ConstructorParameters[0] + +/** + * The page plugin's context, scripted down to the namespaces the page reaches. + * One context per face, as in production: an editor effect keyed by the context + * would otherwise re-probe on every render. + */ +const contexts = new WeakMap() +function ctxWith(face: object): PageContext { + const existing = contexts.get(face) + if (existing !== undefined) return existing + const ctx = { remote: face } as unknown as PageContext + contexts.set(face, ctx) + return ctx +} /** The settings write one card produced, as the scripted face recorded it. */ interface MutateCall { @@ -152,12 +184,12 @@ function firstMutate(mutate: ReturnType): MutateCall { async function mountSection(options: Parameters[0] = {}) { const scripted = scriptedFace(options) const controller = new ModelsSettingsStore( - scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never)) + ctxWith(scripted.face), settingsSchema, new SettingsDescribeMirror(ctxWith(scripted.face))) await controller.load() const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - api: scripted.face as never, + ctx: ctxWith(scripted.face), schema: settingsSchema, t, renderSlot: () => null, @@ -505,7 +537,7 @@ describe('endpoint interrogation', () => { it('keeps the rows editable when the provider cannot be interrogated', async () => { const discover = vi.fn(() => Promise.resolve( - fail('https://proxy.example/v1/models answered 401; check the API key', 'model-discovery-failed'), + fail('https://proxy.example/v1/models answered 401; check the API key', 'llm/model-discovery-rejected'), )) await mountSection({ discover }) openEditor('openai') @@ -517,19 +549,12 @@ describe('endpoint interrogation', () => { expect(screen.getByRole('button', { name: en.addModel })).toBeTruthy() }) - it('reports an empty listing and a rejected transport', async () => { + it('reports an empty listing', async () => { const empty = vi.fn(() => Promise.resolve(ok([]))) await mountSection({ discover: empty }) openEditor('openai') fireEvent.click(screen.getByText(en.fetchModels)) await screen.findByText(en.fetchEmpty) - cleanup() - - const rejected = vi.fn(() => Promise.reject(new Error('carrier down'))) - await mountSection({ discover: rejected }) - openEditor('openai') - fireEvent.click(screen.getByText(en.fetchModels)) - await screen.findByText('carrier down') }) it('can be asked for a configured route even with no endpoint', async () => { @@ -551,7 +576,7 @@ describe('endpoint interrogation', () => { const scripted = scriptedFace() render( , ) @@ -671,12 +696,12 @@ describe('provider rows', () => { settingsPath: ['providers', 'openai'], }]))) as never const controller = new ModelsSettingsStore( - scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never)) + ctxWith(scripted.face), settingsSchema, new SettingsDescribeMirror(ctxWith(scripted.face))) await controller.load() render( null} @@ -700,7 +725,7 @@ describe('hand-declared providers', () => { taken={['openai']} protocols={PROTOCOLS} revision={7} - api={scripted.face as never} + ctx={ctxWith(scripted.face)} t={t} readOnly={false} onClose={onClose} @@ -1125,9 +1150,9 @@ describe('hand-declared providers', () => { expect(buttonNamed(en.create).disabled).toBe(false) }) - it('surfaces a refused write and a rejected transport without closing', async () => { - const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings-rejected'))) - const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never }) + it('surfaces a refused write without closing', async () => { + const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings/rejected'))) + const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: refused }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1139,9 +1164,9 @@ describe('hand-declared providers', () => { expect(onClose).not.toHaveBeenCalled() }) - it('surfaces a rejected transport during create', async () => { - const rejecting = vi.fn(() => Promise.reject(new Error('carrier down'))) - const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: rejecting }).face } as never }) + it('translates a create refused by a newer namespace revision', async () => { + const conflicting = vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))) + const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: conflicting }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1149,13 +1174,13 @@ describe('hand-declared providers', () => { fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) fireEvent.click(screen.getByText(en.create)) - await screen.findByText('carrier down') + await screen.findByText(en.conflict) expect(onClose).not.toHaveBeenCalled() }) it('reports a stored profile whose key write was refused', async () => { const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))) - const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never }) + const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ set }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 5f9aced5bb..7757cf3792 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -1,27 +1,28 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ import { describe, expect, it } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { messageOf, ModelsSettingsStore } from '../src/client/store.ts' +import { ModelsSettingsStore } from '../src/client/store.ts' let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } } function fail(message: string): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } } + return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'gateway/internal', message, details: {} } } } } -/** Credentials answers over the Remote carrier, which has no envelope. */ +/** Answers over the Remote carrier, which has no envelope. */ type RemoteAnswer = | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: { code: string; message: string; details: object } } + | { readonly ok: false; readonly error: RemoteError } function remoteOk(value: T): RemoteAnswer { return { ok: true, value } } function remoteFail(message: string): RemoteAnswer { - return { ok: false, error: { code: 'internal', message, details: {} } } + return { ok: false, error: new RemoteError('gateway/internal', message, {}) } } const DIRECTORY = [ @@ -102,14 +103,15 @@ function api(overrides: { unset: () => Promise.resolve(remoteOk(undefined)), }, } - const wire = face as never - return { face: wire, mirror: new SettingsDescribeMirror(wire), seenRefs } + // The page plugin's context, scripted down to the namespaces it reaches. + const ctx = { remote: face } as never + return { ctx, face, mirror: new SettingsDescribeMirror(ctx), seenRefs } } describe('ModelsSettingsStore', () => { it('joins rows with configured, removable, and credential state', async () => { - const { face, mirror, seenRefs } = api() - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror, seenRefs } = api() + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -138,8 +140,8 @@ describe('ModelsSettingsStore', () => { }) it('degrades the credential badge, not the page, when the credential domain fails', async () => { - const { face, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -147,34 +149,13 @@ describe('ModelsSettingsStore', () => { expect(state.rows.every(row => row.credential === undefined)).toBe(true) }) - it('settles a credential transport rejection without leaving the store loading', async () => { - const { face, mirror } = api({ - describeCredentials: () => Promise.reject(new Error('credential transport down')), - }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) - await expect(store.load()).resolves.toBeUndefined() - expect(store.store.getSnapshot()).toMatchObject({ - status: 'ready', - credentialError: 'credential transport down', - }) - }) - - it('stringifies a non-Error credential transport rejection', async () => { - const { face, mirror } = api({ - describeCredentials: async () => { throw 'credential transport refusal' }, - }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) - await expect(store.load()).resolves.toBeUndefined() - expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') - }) - it('surfaces a directory failure and keeps the last good rows', async () => { - const { face, mirror } = api() - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror } = api() + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot().rows).toHaveLength(4) const broken = api({ providers: () => Promise.resolve(fail('directory down')) }) - const failing = new ModelsSettingsStore(broken.face, settingsSchema, broken.mirror) + const failing = new ModelsSettingsStore(broken.ctx, settingsSchema, broken.mirror) await failing.load() expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' }) // The first store's snapshot is untouched by the second's failure. @@ -182,12 +163,12 @@ describe('ModelsSettingsStore', () => { }) it('surfaces a configurable-provider directory failure', async () => { - const { face, mirror } = api() + const { ctx, face, mirror } = api() const llm = (face as unknown as { llm: { listConfigurableProviders: () => Promise> } }).llm llm.listConfigurableProviders = () => Promise.resolve(remoteFail('configuration directory down')) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() @@ -200,7 +181,7 @@ describe('ModelsSettingsStore', () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) let call = 0 - const { face, mirror } = api({ + const { ctx, mirror } = api({ providers: async () => { call += 1 if (call === 1) { @@ -210,7 +191,7 @@ describe('ModelsSettingsStore', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) const first = store.load() const second = store.load() release?.() @@ -221,7 +202,7 @@ describe('ModelsSettingsStore', () => { describe('edge joins', () => { it('treats a non-object profile as having no credential reference', async () => { - const { face, mirror } = api({ + const { ctx, mirror } = api({ describeSettings: () => Promise.resolve(remoteOk({ writable: true, hasDocument: false, @@ -240,7 +221,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() expect(state.rows[0]).toMatchObject({ configured: true, removable: false }) @@ -248,7 +229,7 @@ describe('edge joins', () => { }) it('describes the derived reference for a row whose profile names none', async () => { - const { face, mirror, seenRefs } = api({ + const { ctx, mirror, seenRefs } = api({ describeSettings: () => Promise.resolve(remoteOk({ writable: true, hasDocument: false, @@ -263,7 +244,7 @@ describe('edge joins', () => { Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), )), }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() // The dormant row names no reference, so the join asks about the page's // own derived _API_KEY — what the editor would display for it. @@ -275,18 +256,18 @@ describe('edge joins', () => { }) it('surfaces a settings describe failure', async () => { - const { face, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) it('reports a terminally unavailable settings mirror precisely', async () => { - const { face } = api() + const { ctx } = api() const store = new ModelsSettingsStore( - face, + ctx, settingsSchema, - new SettingsDescribeMirror(face, 'memory'), + new SettingsDescribeMirror(ctx, 'memory'), ) await store.load() expect(store.store.getSnapshot()).toMatchObject({ @@ -297,7 +278,7 @@ describe('edge joins', () => { it('reuses a held settings view after its refresh fails', async () => { let settingsCall = 0 - const { face, mirror } = api({ + const { ctx, mirror } = api({ describeSettings: () => { settingsCall += 1 return Promise.resolve(settingsCall === 1 @@ -305,7 +286,7 @@ describe('edge joins', () => { : remoteFail('settings refresh down')) }, }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() await mirror.load() expect(mirror.getSnapshot().error).toBe('settings refresh down') @@ -314,19 +295,11 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) - it('stringifies a non-Error load failure', async () => { - // The wire can surface non-Error throwables; the store must stringify them. - const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) - await store.load() - expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) - }) - it('drops a stale successful response after a newer load finished', async () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) let call = 0 - const { face, mirror } = api({ + const { ctx, mirror } = api({ providers: async () => { call += 1 if (call === 1) { @@ -336,7 +309,7 @@ describe('edge joins', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) const first = store.load() const second = store.load() await second @@ -346,13 +319,3 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) }) - -describe('messageOf', () => { - it('reads an Error message, and stringifies anything else a rejection may carry', () => { - // The wire layer rejects with an Error, but a host or a runtime can reject - // with any value, and the page still has to render something. - expect(messageOf(new Error('connection lost'))).toBe('connection lost') - expect(messageOf('the host refused')).toBe('the host refused') - expect(messageOf(undefined)).toBe('undefined') - }) -}) 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 1a48af9d1c..efa8c01665 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 @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, 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 { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -70,9 +70,10 @@ function mount( mutate, }, } - const mirror = new SettingsDescribeMirror(api as never) + const ctx = { remote: api } as never + const mirror = new SettingsDescribeMirror(ctx) const scope = new SettingsScopeController( - api as never, + ctx, { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, mirror, 'host', @@ -153,15 +154,8 @@ describe('WelcomeNotice', () => { fireEvent.click(action) expect(action.disabled).toBe(true) resolveWrite({ - rpcId: 'welcome-refused' as never, - result: { - ok: false, - error: { - code: 'settings-rejected', - message: 'read only', - details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }, - }, - }, + ok: false, + error: new RemoteError('settings/rejected', 'read only', { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }), }) expect((await screen.findByRole('alert')).textContent).toBe(zh.welcomeError) expect(h.complete).not.toHaveBeenCalled() diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index d0927e34f0..cca91259c4 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, @@ -15,6 +16,13 @@ function ok(value: T) { return { ok: true as const, value } } +function rejected(message: string) { + return { + ok: false as const, + error: new RemoteError('settings/rejected', message, { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }), + } +} + function namespace(value: unknown = {}, revision = 0) { return { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -35,10 +43,10 @@ function buildWelcome( api: { describe?: ReturnType; mutate?: ReturnType }, persistence: 'host' | 'memory' = 'host', ) { - const wire = { settings: api } as never - const mirror = new SettingsDescribeMirror(wire, persistence) + const ctx = { remote: { settings: api } } as never + const mirror = new SettingsDescribeMirror(ctx, persistence) const scope = new SettingsScopeController( - wire, + ctx, { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, mirror, persistence, @@ -109,11 +117,11 @@ describe('WelcomeNoticeStore', () => { expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null }) }) - it('reports a failed or refused persistence attempt after its recovery read', async () => { + it('reports a refused persistence attempt after its recovery read', async () => { const describeCall = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()], }))) - const mutate = vi.fn(() => Promise.reject(new Error('disk full'))) + const mutate = vi.fn(() => Promise.resolve(rejected('the settings document is read-only'))) const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) await mirror.load() await controller.load() diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index c0cdbaf6b7..73e7d06cbd 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -48,7 +47,6 @@ "peerDependencies": { "@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-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -57,7 +55,6 @@ "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-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 40ec376432..46529882bc 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -54,7 +54,7 @@ const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', ] /** @@ -68,10 +68,10 @@ export function apply(ctx: ClientContext): void { const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController( - ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) + ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx) const subagentModelSelection = new SubagentModelSelectionCardController( ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), - ctx.remote.session, + ctx, ) // The credential a card reports is not part of any settings section, so its diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 9e1b5c2d2a..96e5b8c494 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -1,9 +1,7 @@ /** Staged editor for the Host-owned subagent model allowlist. */ -import type { - ClientRemote, - ModelProviderGroup, -} from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CardShell } from './card-form.ts' @@ -145,11 +143,12 @@ export class SubagentModelSelectionCardController { /** * @param scope - bound `subagent-model-selection` settings scope. - * @param session - Host Session model-catalog face. + * @param ctx - the card plugin's context, whose `remote.session` namespace + * answers the Host model catalog. */ constructor( private readonly scope: SettingsScope, - private readonly session: Pick, + private readonly ctx: ClientContext, ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { @@ -320,15 +319,13 @@ export class SubagentModelSelectionCardController { this.catalogStatus = 'loading' this.catalogPartial = false this.publish() - try { - const response = await this.session.modelCatalog() - if (generation !== this.catalogGeneration) return - if (!response.ok) throw new Error(response.error.message) + const response = await this.ctx.remote.session.modelCatalog() + if (generation !== this.catalogGeneration) return + if (response.ok) { this.catalogGroups = response.value.groups this.catalogPartial = response.value.failures.length > 0 this.catalogStatus = 'ready' - } catch { - if (generation !== this.catalogGeneration) return + } else { this.catalogStatus = 'error' } this.publish() 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 924ba688d5..35990763bb 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 @@ -9,7 +9,9 @@ * covers everything the card shows. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { @@ -39,9 +41,6 @@ export interface WebSearchSettings { maxUses?: number } -/** The credentials Remote methods this card reads and writes through. */ -export type WebSearchCredentials = Pick - /** What the credentials domain last reported, and for which reference. */ interface CredentialState { /** Reference this answer describes; a stale response for another one is dropped. */ @@ -82,11 +81,12 @@ export class WebSearchCardController { /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. - * @param credentials - Remote face used for the credential the section references. + * @param ctx - the card plugin's context, whose `remote.credentials` namespace + * answers for the credential the section references. */ constructor( private readonly scope: SettingsScope, - private readonly credentials: WebSearchCredentials, + private readonly ctx: ClientContext, ) { this.form = new CardForm( scope, @@ -125,14 +125,7 @@ export class WebSearchCardController { this.credential = { ref, configured: false, writable: true } this.store.set(this.projection()) } - let response: Awaited> - try { - response = await this.credentials.describe([ref]) - } catch (_credentialReadFailure) { - // The card stays usable without this: the key control simply reports the - // last state it knew, and a write still reaches the Host. - return - } + const response = await this.ctx.remote.credentials.describe([ref]) if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return const view = response.value[ref] const next: CredentialState = { @@ -174,12 +167,9 @@ export class WebSearchCardController { * @returns whether the Host reports a configured credential afterwards. */ private async writeKey(value: string): Promise { - try { - await this.credentials.set(refOf(this.scope.getSnapshot()), value) - } catch (_credentialWriteFailure) { - // Refusals surface through the re-read below: the Host is the only - // authority on whether the key now exists. - } + // Refusals surface through the re-read below: the Host is the only + // authority on whether the key now exists. + await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value) await this.readCredential() return this.credential.configured } 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 6bc979abc6..54090a4e1b 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -4,8 +4,8 @@ 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-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' 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' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { @@ -27,12 +27,14 @@ async function bench(served?: string[]) { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) + const describeCredentials = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'no provider', {}), + })) const models = vi.fn(() => Promise.resolve({ ok: true as const, value: { groups: [], failures: [] }, })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined - ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } : { ok: true, value: { @@ -48,9 +50,6 @@ async function bench(served?: string[]) { session: { modelCatalog: models }, settings: { describe: describeSettings }, }) - ctx.provide('connection', { - isLoopback: true, - } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, @@ -67,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', ]) }) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index ffdb0d8cf3..5f4e13259d 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' -import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' @@ -46,13 +46,18 @@ function acceptWrites(host: StubSettingsScope): void { }) } +/** The card plugin's context, scripted down to the namespaces a card reaches. */ +function ctxWith(namespaces: object) { + return { remote: namespaces } as never +} + function credentialsApi(configured: boolean) { const describe = vi.fn(() => Promise.resolve({ ok: true as const, value: { DEEPSEEK_API_KEY: { configured, writable: true } }, })) const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) - return { api: { describe, set } as never, describe, set } + return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } } function modelsApi(options: { @@ -67,9 +72,9 @@ function modelsApi(options: { const models = vi.fn(() => Promise.resolve({ ...(options.error === undefined ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } - : { ok: false as const, error: { code: 'internal' as const, message: options.error, details: {} } }), + : { ok: false as const, error: new RemoteError('gateway/internal', options.error, {}) }), })) - return { api: { modelCatalog: models } as never, models } + return { ctx: ctxWith({ session: { modelCatalog: models } }), models } } function deferred() { @@ -454,7 +459,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 3, value: { enabled: false, allowedModels: [] }, user: {}, @@ -485,7 +490,7 @@ describe('SubagentModelSelectionCardController', () => { it('starts an empty draft when a ready test scope has no decoded value', () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx) host.publish({ status: 'ready', writable: true, revision: 0, value: undefined }) const face = controller.inject() @@ -501,7 +506,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() @@ -528,7 +533,7 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], failures: [{ id: 'beta', name: 'Beta', message: 'offline' }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 5, value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, @@ -561,7 +566,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) const face = controller.inject() await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() }) @@ -581,7 +586,7 @@ describe('SubagentModelSelectionCardController', () => { it('reports a directory error and retries it', async () => { const host = stubSettingsScope() const models = modelsApi({ error: 'offline' }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -597,7 +602,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 4, value: { enabled: false, allowedModels: [] }, user: {}, @@ -631,7 +636,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 4, value: { enabled: false, allowedModels: [] }, user: {}, @@ -668,7 +673,7 @@ describe('SubagentModelSelectionCardController', () => { }) .mockImplementationOnce(() => refreshed.promise) const controller = new SubagentModelSelectionCardController( - host.scope, { modelCatalog: models }, + host.scope, ctxWith({ session: { modelCatalog: models } }), ) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -707,7 +712,7 @@ describe('SubagentModelSelectionCardController', () => { status: 'ready', writable: true, revision: 4, value: { enabled: false, allowedModels: [] }, user: {}, }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) const face = controller.inject() face.toggleEnabled() await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) @@ -747,7 +752,7 @@ describe('SubagentModelSelectionCardController', () => { }, }) const controller = new SubagentModelSelectionCardController( - host.scope, { modelCatalog: models }, + host.scope, ctxWith({ session: { modelCatalog: models } }), ) const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) @@ -773,7 +778,7 @@ describe('SubagentModelSelectionCardController', () => { allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [], } }) }) - const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api) + const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx) const face = controller.inject() face.save() @@ -796,25 +801,25 @@ describe('SubagentModelSelectionCardController', () => { expect(mutate).toHaveBeenCalledOnce() }) - it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { + it('suppresses duplicate directory loads and late settlements', async () => { const host = stubSettingsScope() host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const pending = deferred() const models = vi.fn(() => pending.promise) - const controller = new SubagentModelSelectionCardController(host.scope, { modelCatalog: models }) + const controller = new SubagentModelSelectionCardController(host.scope, ctxWith({ session: { modelCatalog: models } })) const face = controller.inject() face.toggleEnabled() face.retryCatalog() expect(models).toHaveBeenCalledOnce() controller.dispose() - pending.reject(new Error('late failure')) - await pending.promise.catch(() => undefined) + pending.resolve({ ok: false, error: new RemoteError('gateway/internal', 'late failure', {}) } as never) + await pending.promise const pendingResolve = deferred() const resolving = new SubagentModelSelectionCardController( host.scope, - { modelCatalog: () => pendingResolve.promise }, + ctxWith({ session: { modelCatalog: () => pendingResolve.promise } }), ) const resolvingFace = resolving.inject() resolvingFace.toggleEnabled() @@ -827,7 +832,7 @@ describe('SubagentModelSelectionCardController', () => { it('ignores writes while read-only and scope notifications after disposal', () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx) host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() @@ -852,7 +857,7 @@ describe('WebSearchCardController', () => { it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) const state = () => controller.inject().hooks.webSearchCard.getSnapshot() await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) @@ -868,7 +873,7 @@ describe('WebSearchCardController', () => { it('writes the staged key through the credentials domain, never the settings section', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) const face = controller.inject() @@ -893,7 +898,7 @@ describe('WebSearchCardController', () => { it('keeps the stored key when the draft is left blank', () => { const host = stubSettingsScope() const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) const face = controller.inject() @@ -908,7 +913,7 @@ describe('WebSearchCardController', () => { it('re-reads when the Host reports the watched reference changed', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) credentials.describe.mockClear() @@ -932,7 +937,7 @@ describe('WebSearchCardController', () => { it('addresses the reference the tab declares rather than the default', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) const face = controller.inject() @@ -946,7 +951,7 @@ describe('WebSearchCardController', () => { it('reports a key the Host did not store as a failed save', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) const face = controller.inject() @@ -958,11 +963,15 @@ describe('WebSearchCardController', () => { }) }) - it('keeps the card usable when the credential read fails', async () => { + it('keeps the card usable when the credential read is refused', async () => { const host = stubSettingsScope() - const describe = vi.fn(() => Promise.reject(new Error('offline'))) - const set = vi.fn(() => Promise.reject(new Error('offline'))) - const controller = new WebSearchCardController(host.scope, { describe, set }) + const refusal = () => Promise.resolve({ + ok: false as const, + error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), + }) + const describe = vi.fn(refusal) + const set = vi.fn(refusal) + const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } })) const face = controller.inject() await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) @@ -982,9 +991,11 @@ describe('WebSearchCardController', () => { const host = stubSettingsScope() const describe = vi.fn(() => Promise.resolve({ ok: false as const, - error: { code: 'internal', message: 'no credential provider', details: {} }, + error: new RemoteError('gateway/internal', 'no credential provider', {}), + })) + const controller = new WebSearchCardController(host.scope, ctxWith({ + credentials: { describe, set: vi.fn() }, })) - const controller = new WebSearchCardController(host.scope, { describe, set: vi.fn() }) await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) @@ -994,7 +1005,7 @@ describe('WebSearchCardController', () => { const host = stubSettingsScope() acceptWrites(host) const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) const face = controller.inject() @@ -1020,7 +1031,7 @@ describe('ConfigurablePluginsTabController', () => { })), }, })) - return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe } + return { mirror: new SettingsDescribeMirror(ctxWith({ settings: { describe } })), describe } } /** Slot ledger stand-in: one stored entry per registered card key. */ diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 6c5bdc2be3..02f6a598bb 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes" ], "platform": "web" @@ -49,7 +48,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" }, @@ -62,8 +60,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 23f289e527..d595952c3c 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -12,9 +12,9 @@ * Export discipline: packages/client/AGENTS.md. */ 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: the ctx.remote merge, the fixed Host facts, and the carrier's +// `connection/reset` lifecycle event, all through the assembly package. +import type {} from '@deepseek-ai/dsh-api-remotes/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). @@ -33,14 +33,14 @@ export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './ export type { SettingsSchemaService } from './schema.ts' export type { SchemaNode } from './schema.ts' export type { - SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot, SettingsRemote, SettingsWireFace, + SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot, } from './settings-mirror.ts' /** - * Required services: the wire handle for the mirror's reads and the forwarded - * settings invalidation the mirror refreshes on. + * Required services: the Remote namespace the mirror reads through and the + * forwarded settings invalidation it refreshes on. */ -export const inject = ['connection', 'remote', 'remote.settings'] +export const inject = ['remote', 'remote.settings'] /** * Provide the settings-namespace scope service over one shared describe @@ -53,11 +53,10 @@ export const inject = ['connection', 'remote', 'remote.settings'] */ export function apply(ctx: Context): void { const schema = new SettingsSchemaService(ctx) - const connection = ctx.get('connection') as ConnectionHandle - // Captured once here, where `remote.settings` is declared in this plugin's - // own `inject`; the binder hands the same face to every scope it binds. - const wire = { settings: ctx.remote.settings } - const mirror = new SettingsDescribeMirror(wire, connection.isLoopback ? 'host' : 'memory') + // Resolved once here, where `remote` is declared in this plugin's own + // `inject`; the binder hands the same answer to every scope it binds. + const persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory' + const mirror = new SettingsDescribeMirror(ctx, persistence) ctx.effect(() => { const disposers = [ ctx.remote.$on('settings/document-updated', () => { void mirror.load() }), @@ -70,5 +69,5 @@ export function apply(ctx: Context): void { void mirror.ensure() return () => { for (const dispose of disposers) dispose() } }, 'ui-settings: describe mirror invalidations') - new SettingsScopeBinder(ctx, { mirror, schema, wire }) + new SettingsScopeBinder(ctx, { mirror, schema, persistence }) } diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index f7a6c15a22..d26f0b8c94 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -9,25 +9,10 @@ * through {@link SettingsDescribeMirror.acceptView}. */ -import type { ClientRemote, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -/** - * The settings Remote methods browser configuration surfaces may reach: the - * redacted read plus merge, replacement, and path-addressed writes. - * Named once here so the consumers share one face instead of each re-deriving - * it from the namespace. - */ -export type SettingsRemote = Pick - -/** Wire face carrying the settings Remote namespace. */ -export interface SettingsWireFace { - /** The settings Remote namespace. */ - settings: SettingsRemote -} - -type SettingsFace = SettingsWireFace - /** The full `settings.describe` answer the mirror serves. */ export interface SettingsDescribeView { /** Every namespace a live Host plugin registered, as the Host reported it. */ @@ -92,11 +77,12 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { private generation = 0 /** - * @param api - settings wire face. + * @param ctx - the providing plugin's context, whose `remote.settings` + * namespace answers the describe read. * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. */ constructor( - private readonly api: SettingsFace, + private readonly ctx: ClientContext, private readonly persistence: 'host' | 'memory' = 'host', ) { this.store = createSnapshotStore({ @@ -194,7 +180,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { const generation = ++this.generation let outcome: { view: SettingsDescribeView } | { failure: string } try { - const response = await this.api.settings.describe() + const response = await this.ctx.remote.settings.describe() outcome = response.ok ? { view: response.value } : { failure: response.error.message } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 2d728e5154..305c94da37 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -9,7 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { - ConnectionHandle, JsonValue, SettingsNamespaceView, SettingsPathOpView, + JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' // Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this @@ -30,9 +30,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' 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, type SettingsWireFace } from './settings-mirror.ts' - -type SettingsFace = SettingsWireFace +import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' /** * One namespace's derived view over the shared describe mirror, plus that @@ -54,14 +52,15 @@ export class SettingsScopeController implements SettingsScope { private pendingRevision: number | undefined /** - * @param api - settings wire face (writes only; reads ride the mirror). + * @param ctx - the providing plugin's context, whose `remote.settings` + * namespace carries this scope's writes (reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. * @param mirror - the shared describe mirror this scope derives from. * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. * @param schema - settings-owned schema operations. */ constructor( - private readonly api: SettingsFace, + private readonly ctx: Context, private readonly spec: SettingsScopeSpec, private readonly mirror: SettingsDescribeMirror, private readonly persistence: 'host' | 'memory', @@ -128,13 +127,7 @@ export class SettingsScopeController implements SettingsScope { const generation = ++this.writeGeneration return this.enqueue(async () => { const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision - let response: Awaited> - try { - response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) - } catch (_settingsWriteFailure) { - await this.recover(generation) - return - } + const response = await this.ctx.remote.settings.mutate(this.spec.namespace, ownedOps, revision) if (!response.ok) { await this.recover(generation) return @@ -238,26 +231,30 @@ declare module '@deepseek-ai/cordis' { export class SettingsScopeBinder extends Service { private readonly mirror: SettingsDescribeMirror private readonly schema: SettingsSchemaService - private readonly wire: SettingsWireFace + private readonly persistence: 'host' | 'memory' + /** + * The PROVIDING fiber, kept because a Service reads `ctx` as its *consumer's* + * fiber: letting a bound scope write through the caller's context would make + * every caller declare `remote.settings` in its own `inject`. + */ + private readonly owner: Context /** * @param ctx - the providing plugin's context. * @param config - the shared describe mirror every bound scope derives from, - * the settings-owned schema operations, and the settings Remote namespace the - * bound scopes write through. The namespace is captured here rather than read - * inside {@link bind}, because a Service reads `ctx` as its *consumer's* - * fiber: reading it there would make every caller declare `remote.settings` - * in its own `inject`. + * the settings-owned schema operations, and the Host persistence the provider + * resolved from `remote.$host`. */ constructor(ctx: Context, config: { mirror: SettingsDescribeMirror schema: SettingsSchemaService - wire: SettingsWireFace + persistence: 'host' | 'memory' }) { super(ctx, 'settingsScope') this.mirror = config.mirror this.schema = config.schema - this.wire = config.wire + this.persistence = config.persistence + this.owner = ctx } /** @@ -283,12 +280,11 @@ export class SettingsScopeBinder extends Service { */ bind(spec: SettingsScopeSpec): SettingsScope { const ctx = this.ctx - const connection = ctx.get('connection') as ConnectionHandle const controller = new SettingsScopeController( - this.wire, + this.owner, spec, this.mirror, - connection.isLoopback ? 'host' : 'memory', + this.persistence, this.schema, ) ctx.effect(() => { diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index a5a7a85053..47aad4b686 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -10,7 +10,6 @@ function bench() { ok: true, value: { writable: true, hasDocument: true, namespaces: [] }, }) const ctx = new Context() - ctx.provide('connection', { api: {}, isLoopback: true } as never) const remote = new TestRemote(ctx, { settings: { describe: describeCall } }) return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 92a5c0fa11..3bb67fe3f0 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -1,18 +1,24 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts' -/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */ +/** What a Remote call answers with: no carrier envelope, and a typed failure. */ type Answer = | { ok: true; value: T } - | { ok: false; error: { code: string; message: string; details: object } } + | { ok: false; error: RemoteError } function ok(value: T): Answer { return { ok: true, value } } function rejected(message: string): Answer { - return { ok: false, error: { code: 'settings-rejected', message, details: { ns: 'theme' } } } + return { ok: false, error: new RemoteError('settings/rejected', message, { ns: 'theme' }) } +} + +/** The providing plugin's context, scripted down to the one method the mirror calls. */ +function ctxWith(describeCall: unknown) { + return { remote: { settings: { describe: describeCall } } } as never } function view(ns: string, revision = 0): SettingsNamespaceView { @@ -35,7 +41,7 @@ describe('SettingsDescribeMirror', () => { const describeCall = vi.fn() .mockReturnValueOnce(gate.promise) .mockResolvedValue(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) const first = mirror.load() // Issued before the wire read goes out: covered by that read, no rerun. const early = mirror.load() @@ -56,7 +62,7 @@ describe('SettingsDescribeMirror', () => { .mockResolvedValueOnce(described([view('theme', 2)])) .mockRejectedValueOnce(new Error('host gone')) .mockResolvedValueOnce(rejected('busy')) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null }) await mirror.load() @@ -71,7 +77,7 @@ describe('SettingsDescribeMirror', () => { const describeCall = vi.fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.ensure() expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' }) await mirror.ensure() @@ -81,7 +87,7 @@ describe('SettingsDescribeMirror', () => { it('treats ensure as a no-op once ready', async () => { const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.ensure() await mirror.ensure() await mirror.ensure() @@ -90,7 +96,7 @@ describe('SettingsDescribeMirror', () => { it('memory persistence is terminally unavailable and never touches the wire', async () => { const describeCall = vi.fn() - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory') + const mirror = new SettingsDescribeMirror(ctxWith(describeCall), 'memory') await mirror.ensure() await mirror.load() expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null }) @@ -100,7 +106,7 @@ describe('SettingsDescribeMirror', () => { it('acceptView folds one write answer into the held view without a wire read', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() const seen: number[] = [] mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) }) @@ -113,14 +119,14 @@ describe('SettingsDescribeMirror', () => { it('acceptView before any answer is a no-op instead of inventing a document', () => { const describeCall = vi.fn() - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) mirror.acceptView(view('theme', 1)) expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null }) }) it('acceptView appends a namespace the held view has not seen yet', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() mirror.acceptView(view('fresh-ns', 0)) expect(mirror.namespace('fresh-ns')).toBeDefined() @@ -132,7 +138,7 @@ describe('SettingsDescribeMirror', () => { // a load() in the one-microtask gap after the rerun check marked a rerun // nobody read, and that refresh never reached the wire. const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) void mirror.load() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) void mirror.load() @@ -144,7 +150,7 @@ describe('SettingsDescribeMirror', () => { it('starts no second run for a load issued inside the loading publish', async () => { const gate = deferred>() const describeCall = vi.fn().mockReturnValue(gate.promise) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) let reentered = false const unsubscribe = mirror.subscribe(() => { if (reentered) return @@ -164,7 +170,7 @@ describe('SettingsDescribeMirror', () => { it('lets the first read cover a write folded inside the loading publish', async () => { const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) const unsubscribe = mirror.subscribe(() => { unsubscribe() mirror.acceptView(view('theme', 2)) @@ -183,7 +189,7 @@ describe('SettingsDescribeMirror', () => { .mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)])) .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() expect(describeCall).toHaveBeenCalledTimes(1) const stale = mirror.load() @@ -201,7 +207,7 @@ describe('SettingsDescribeMirror', () => { const describeCall = vi.fn() .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(described([view('theme', 2)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) const loading = mirror.load() await Promise.resolve() mirror.acceptView(view('theme', 2)) 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 25091897f0..b1d1062b78 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import type { JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' 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' @@ -20,17 +20,22 @@ const ENVELOPE = z.object({ preference: z.union(['light', 'dark', 'system']).default('system'), }).toJSON() -/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */ +/** What a Remote call answers with: no carrier envelope, and a typed failure. */ type Answer = | { ok: true; value: T } - | { ok: false; error: { code: string; message: string; details: object } } + | { ok: false; error: RemoteError } function ok(value: T): Answer { return { ok: true, value } } function rejected(): Answer { - return { ok: false, error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } } } + return { ok: false, error: new RemoteError('settings/rejected', 'conflict', { ns: 'ui-test' }) } +} + +/** The providing plugin's context, scripted down to the settings namespace. */ +function ctxWith(settings: object) { + return { remote: { settings } } as never } function view(value: JsonValue, revision = 0): SettingsNamespaceView { @@ -57,14 +62,14 @@ function deferred() { return { promise, resolve, reject } } -/** A host-mode mirror plus a controller derived from it, over one fake wire. */ +/** A host-mode mirror plus a controller derived from it, over one scripted context. */ function derivedScope( api: { describe?: ReturnType; mutate?: ReturnType }, spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' }, ) { - const wire = { settings: api } as never - const mirror = new SettingsDescribeMirror(wire) - const scope = new SettingsScopeController(wire, spec, mirror, 'host', settingsSchema) + const ctx = ctxWith(api) + const mirror = new SettingsDescribeMirror(ctx) + const scope = new SettingsScopeController(ctx, spec, mirror, 'host', settingsSchema) return { mirror, scope } } @@ -229,10 +234,10 @@ describe('SettingsScopeController', () => { it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) - const wire = { settings: { describe: describeCall, mutate } } as never - const mirror = new SettingsDescribeMirror(wire) - const writer = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) - const sibling = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + const ctx = ctxWith({ describe: describeCall, mutate }) + const mirror = new SettingsDescribeMirror(ctx) + const writer = new SettingsScopeController(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + const sibling = new SettingsScopeController(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) await mirror.load() await writer.set('preference', 'dark') expect(describeCall).toHaveBeenCalledTimes(1) @@ -262,13 +267,13 @@ describe('SettingsScopeController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 }) }) - it('recovers the latest rejected or thrown write from Host state', async () => { + it('recovers the latest refused write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) .mockResolvedValueOnce(described({ preference: 'light' }, 3)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(rejected()) const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) await mirror.load() @@ -277,11 +282,11 @@ describe('SettingsScopeController', () => { expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) }) - it('does not recover superseded rejected or thrown writes', async () => { + it('does not recover superseded refused writes', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(rejected()) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) @@ -415,9 +420,8 @@ describe('SettingsScopeController', () => { return () => {} }, } as never - const wire = { settings: {} } as never const scope = new SettingsScopeController( - wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + ctxWith({}), { namespace: 'ui-test' }, mirror, 'host', settingsSchema) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) await scope.dispose() @@ -433,10 +437,10 @@ describe('SettingsScopeController', () => { it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() - const wire = { settings: { describe: describeCall, mutate } } as never - const mirror = new SettingsDescribeMirror(wire, 'memory') + const ctx = ctxWith({ describe: describeCall, mutate }) + const mirror = new SettingsDescribeMirror(ctx, 'memory') const scope = new SettingsScopeController( - wire, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema) + ctx, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema) expect(scope.getSnapshot()).toEqual({ status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', }) @@ -510,17 +514,15 @@ describe('SettingsScopeController', () => { describe('SettingsScopeBinder.bind', () => { it('shares one mirror read across bound scopes and disposes each with its fiber', async () => { const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1)) - const wire = { settings: { describe: describeCall } } - const mirror = new SettingsDescribeMirror(wire as never) + const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall })) const ctx = new Context() - ctx.provide('connection', { api: wire, isLoopback: true } as never) let theme!: SettingsScope let locale!: SettingsScope - new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await() + new TestRemote(ctx, { settings: { describe: describeCall } }) + await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, persistence: 'host' }).await() expect(ctx.settingsScope.describe()).toBe(mirror) const fiber = ctx.plugin({ - inject: ['connection', 'remote', 'settingsScope'], + inject: ['remote', 'settingsScope'], apply: (plugin: Context) => { theme = plugin.settingsScope.bind({ namespace: 'ui-test' }) locale = plugin.settingsScope.bind({ namespace: 'ui-test' }) @@ -539,15 +541,13 @@ describe('SettingsScopeBinder.bind', () => { it('binds a remote browser in memory mode without starting a settings read', async () => { const describeCall = vi.fn() - const wire = { settings: { describe: describeCall } } - const mirror = new SettingsDescribeMirror(wire as never, 'memory') + const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall }), 'memory') const ctx = new Context() - ctx.provide('connection', { api: wire, isLoopback: false } as never) let scope!: SettingsScope - new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await() + new TestRemote(ctx, { settings: { describe: describeCall } }) + await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, persistence: 'memory' }).await() const fiber = ctx.plugin({ - inject: ['connection', 'remote', 'settingsScope'], + inject: ['remote', 'settingsScope'], apply: (plugin: Context) => { scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 36d1e9778c..44712b7286 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -58,7 +58,7 @@ interface CatalogFetch { } /** Required services: reference source faces plus the tool-row and locale registries. */ -export const inject = ['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills'] +export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills'] /** * Client plugin body: register the '/' source, dictionaries, and keyed tool row. 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 89111675f3..1c812e460e 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -18,7 +18,8 @@ import { describe, expect, it, vi } from 'vitest' 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 { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' @@ -26,7 +27,7 @@ import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } - | { ok: false; error: { code: string; message: string; details: object } } + | { ok: false; error: RemoteFailure } type ListFn = (payload: object, signal?: AbortSignal) => Promise interface PresentationCapture { @@ -63,7 +64,6 @@ async function bench(list: ListFn, addressed?: SessionId) { const ctx = new Context() let captured: InputTriggerSource | undefined ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } }) - ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -102,13 +102,12 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills']) + expect(inject).toEqual(['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills']) }) it('registers the dedicated skill row and its locale dictionaries', async () => { const ctx = new Context() ctx.provide('inputTriggers', { registerSource: () => () => {} }) - ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: () => undefined }) new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) @@ -146,7 +145,6 @@ describe('apply', () => { // InputTriggerService itself injects 'sessions'; the stub unblocks its fiber. ctx.provide('sessions', {}) await ctx.plugin(InputTriggerService).await() - ctx.provide('connection', {}) new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -183,10 +181,10 @@ describe('candidates: sessionId addressing', () => { it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { const { source } = await bench(() => Promise.resolve({ - ok: false, error: { code: 'internal', message: 'boom', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'boom', {}), })) await expect(source.candidates(proj('s1'), req('co'))) - .rejects.toThrow('skills/list failed: internal: boom') + .rejects.toThrow('skills/list failed: gateway/internal: boom') }) it('does not fetch Agent-bound skills for an addressed child', async () => { @@ -243,7 +241,7 @@ describe('catalog cache', () => { const { source } = await bench((payload) => { payloads.push(payload) return fail - ? Promise.resolve({ ok: false as const, error: { code: 'internal', message: 'boom', details: {} } }) + ? Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'boom', {}) }) : listOk(CATALOG)(payload) }) await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('boom') 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 4c85625ce6..015b42c54d 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -70,7 +70,6 @@ async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() const face = sessionsWith(sessions) ctx.provide('sessions', face) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) 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 52f35b241b..e026945747 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom 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 { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary, SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -576,7 +576,7 @@ describe('SubagentHeaderLineage', () => { const failed = props(catalog({ entries: [], state: 'error', - error: { code: 'internal', message: 'index down', details: {} }, + error: new RemoteError('gateway/internal', 'index down', {}), })) render() hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ })) diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 64bf442b47..48ace2efa0 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -417,7 +417,7 @@ function dynamicToken(name: string): ThemeTokenInspection { * row. `remote` carries the forwarded settings invalidation that * `ctx.settingsScope.bind(spec)` subscribes to on this context. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'remote', 'settingsScope'] /** * Client plugin body: provide the theme service and register the diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index b030523f43..584aae2719 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -50,8 +50,8 @@ async function bench(isLoopback = true) { section[op.path[0]!] = op.value return Promise.resolve({ ok: true as const, value: namespace() }) }) - ctx.provide('connection', { api: {}, isLoopback } as never) const events = new TestRemote(ctx, { settings: { describe, mutate } }) + events.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events, @@ -88,7 +88,7 @@ function fontSizeFaceOf(slots: SlotRegistry) { describe('ui-theme apply', () => { it('declares the slot and locale services', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'settingsScope']) }) it('provides the service, registers localized copy, and registers both rows (declaration before or after apply)', async () => { diff --git a/packages/client/ui-theme/tests/invariant.client.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts index 59516d9159..71c2097b70 100644 --- a/packages/client/ui-theme/tests/invariant.client.spec.ts +++ b/packages/client/ui-theme/tests/invariant.client.spec.ts @@ -24,7 +24,7 @@ describe('invariant companion', () => { it('client apply provides ctx.theme over the slots/locale edges', async () => { // The feature registers its own Appearance settings row with localized // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'settingsScope']) const ctx = new Context() new SlotRegistry(ctx) ctx.provide('connection', { diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 385dc58edf..b9ae865929 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,6 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/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' @@ -15,16 +15,15 @@ import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' -/** Required services: the slot registry and the Host description used for POSIX `~`. */ -export const inject = ['slots', 'connection'] +/** Required services: the slot registry and the Remote face carrying the Host home used for POSIX `~`. */ +export const inject = ['slots', 'remote'] /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - const connection = ctx.get('connection') as ConnectionHandle - const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } }) + const toolInject = () => ({ home: ctx.remote.$host.home }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index ce39262499..bf10b4d3b7 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,4 @@ /** Tool UI slot declarations and their composed component props. */ -import type { ConnectionGenerationState } 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-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -47,20 +46,18 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolConnectionGenerationInjected = { - hooks: { - /** Current Connection generation, bound by the slot renderer. */ - connectionGeneration: ConnectionGenerationState - } +export type ToolHostHomeInjected = { + /** Host account home, absent until the Connection is ready. */ + home: string | undefined } /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index e27656cebb..19df19e054 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostHomeInjected, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 6a15997531..1114d29146 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,8 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, home, t, }: ToolTreeProps) { - const home = useConnectionGeneration(generation => generation?.host.home) const block = node.data.root return ( ) { - const home = useConnectionGeneration(generation => generation?.host.home) + block, cwd, home, t, +}: Pick) { const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) 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 960c81f89b..be428ebf19 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -71,10 +71,6 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), 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 bb0b40b74d..f57005bc88 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 @@ -120,10 +120,6 @@ async function bench(snapshot: ChatSnapshot) { ctx.provide('layout', layout as never) ctx.provide('uiWorkspace', {} as never) new TestRemote(ctx, { session: { openWorkspacePath } }) - ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) locale.register(CONVERSATION_NS, { zh: conversationZh, en: conversationEn }) 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 723b2d1bd1..1077ea8ada 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -3,11 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' 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' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' 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 d61b1fe6b7..032c7c8217 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -361,7 +361,7 @@ describe('DetailsPanel Output section (read)', () => { it('abbreviates a leftover POSIX home path on the read card label', () => { const view = mount(snapshot({ nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })], - }), target, '/tmp/ws', { id: 1, host: { home: '/Users/u' } }) + }), target, '/tmp/ws', '/Users/u') expect(view.getByText('~/notes.md')).toBeTruthy() }) 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 852ffd9ef5..5740154547 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 @@ -2,7 +2,6 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/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' @@ -23,7 +22,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, - generation?: ConnectionGeneration, + home?: string, owners?: ToolCallOwnerProps[], ): ToolTreeProps { const snapshot = {} as SessionSnapshot @@ -50,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'], + home, t, } as unknown as ToolTreeProps } @@ -98,7 +97,7 @@ describe('ToolCallTree', () => { it('abbreviates a POSIX home path in the generic tool summary', () => { const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) - const view = render() + const view = render() expect(view.getByText('~/docs/a.ts')).toBeTruthy() }) }) 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 b20b689724..e301fc4834 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,5 +1,4 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { @@ -144,12 +143,12 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. - * @param generation - optional Connection generation carrying the Host home. + * @param home - optional Host account home for POSIX `~` summaries. * @returns a direct-test renderSlot implementation. */ export function renderToolDetails( t: TranslateNS<'conversation'>, - generation?: ConnectionGeneration, + home?: string, ): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; @@ -158,7 +157,7 @@ export function renderToolDetails( return selector(generation)} + home={home} t={t} /> } 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 4a6cedf03e..2409fd0b9c 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -59,10 +59,6 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -205,10 +201,6 @@ 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.ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 4bf4793de0..dd3971bbb8 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { EMPTY_CONVERSATION_SNAPSHOT, UiConversation, @@ -39,7 +39,6 @@ import { import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' 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 { 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' diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index d7f6be2c8a..d33944a914 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,7 +22,6 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. @@ -88,10 +87,9 @@ export type DirectoryPickingHooks = PropsHooks workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, connectionGeneration }, + home: ctx.remote.$host.home, + hooks: { directoryFlow: browserFlowSource }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index a4e3eb3880..a460afe23a 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,8 +1,7 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, SessionListState, diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index c8a5a15740..a312a9b878 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,10 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useConnectionGeneration, + home, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useConnectionGeneration(generation => generation?.host.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 941c251f56..511c6c33f9 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -58,9 +58,6 @@ async function bench() { binding, fork, } as never) - ctx.provide('connection', { - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) const directoryPicker = { pick: pickDirectory } Object.assign(new TestRemote(ctx), { directoryPicker }) @@ -88,7 +85,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', ]) }) @@ -162,7 +159,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.hooks.connectionGeneration.getSnapshot()).toBeUndefined() + expect(browser.home).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() @@ -181,7 +178,7 @@ describe('ui-workspace apply', () => { const b = await bench() b.search.mockImplementationOnce(async () => ({ ok: false, - error: { code: 'internal', message: 'index unavailable', details: {} }, + error: new RemoteError('gateway/internal', 'index unavailable', {}), }) as never) declare(b.slots, 'sidebar.workspaces') await b.ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index ea349efcc5..15b0afc345 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -34,9 +34,6 @@ beforeEach(() => { localStorage.clear() }) async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.releaseWorkspaceSource() - runtime.ctx.provide('connection', { - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) // The rename flow never picks a directory; the namespace only has to be there // for ui-workspace's inject to settle. const directoryPicker = {} @@ -105,7 +102,7 @@ describe('session rename through the assembled browser', () => { it('a rejected rename keeps the dialog open with the error surfaced', async () => { const runtime = await createRuntime() const rename = vi.fn(async () => ({ - ok: false, error: { code: 'internal', message: 'title write failed', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'title write failed', {}), })) await runtime.sessions.add({ id: SID, diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 22e4062abe..e9933f3ea3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -1,14 +1,13 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/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 type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' @@ -85,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useConnectionGeneration: selector => selector(undefined), + home: undefined, renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -110,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useConnectionGeneration: selector => selector({ id: 1, host: { home: '/home/u' } }), + home: '/home/u', }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 816f78e29c..1c76b8b25f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -6,11 +6,10 @@ import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 3cb8c2adbd..393619bbb7 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -7,7 +7,8 @@ import type { IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' @@ -435,20 +436,20 @@ describe('UiWorkspaceService', () => { await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) b.directoryPicker.onPick = () => Promise.resolve({ - ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'no chooser', {}), }) await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') b.directoryPicker.onList = () => Promise.resolve({ - ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } }, + ok: false, error: new RemoteError('directory-picker/unreadable', 'denied', { path: '/private' }), }) const listFailure = b.uiWorkspace.listDirectory('/private') await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) - await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) + await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-picker/unreadable' } }) b.directoryPicker.onCreateDirectory = () => Promise.resolve({ - ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } }, + ok: false, error: new RemoteError('directory-picker/exists', 'taken', { path: '/home/u/new' }), }) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ - rpcError: { code: 'directory-exists' }, + rpcError: { code: 'directory-picker/exists' }, }) }) }) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index bc27c996d0..45869a5c71 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -415,3 +415,10 @@ export type SessionEvent = { surfaceOp?: SurfaceOp } : object) }[T] + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The named Session does not exist; produced by every layer that resolves a SessionId. */ + 'session/not-found': { readonly sessionId: SessionId } + } +} diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 1de340561b..93b6c6d858 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -8,7 +8,7 @@ import type { TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16, IconRefreshOutline14, IconTrashOutline16, IconUserOutline16, StateDot, @@ -67,7 +67,11 @@ function taskIds(value: string): TeamTaskId[] { return items(value) as TeamTaskId[] } -function failureText(error: Pick): string { +/** + * One failure line for either carrier: a Remote failure, or a Team business + * rejection whose codes stay local to this seam and never ride the wire. + */ +function failureText(error: { readonly code: string; readonly message: string }): string { return `${error.message} (${error.code})` } diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 60109f5b24..64a798f998 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -5,6 +5,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { TeamMemberView as TeamRosterMember, TeamTaskId } from '@deepseek-ai/dsh-experimental-agent-team/client' import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx' import { inject, mountAgentTeamUi } from '../src/client/mount.ts' @@ -51,7 +52,7 @@ async function bench(options: { const remote = new RemoteService(ctx) const failure = { ok: false as const, - error: { code: 'internal', message: 'offline', details: {} }, + error: new RemoteError('gateway/internal', 'offline', {}), } const view = { members: [{ @@ -204,18 +205,18 @@ describe('ui-team browser plugin', () => { it('returns Remote carrier failures unchanged', async () => { const view = await bench({ remoteFailure: 'view' }) const viewActions = (view.entry()!.inject as unknown as () => TeamActionInjected)() - await expect(viewActions.load(SESSION)).resolves.toEqual({ + await expect(viewActions.load(SESSION)).resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'offline', details: {} }, + error: { code: 'gateway/internal', message: 'offline' }, }) const update = await bench({ remoteFailure: 'update' }) const updateActions = (update.entry()!.inject as unknown as () => TeamActionInjected)() await expect(updateActions.updateTask(SESSION, { taskId: TASK_ID, expectedRevision: 1, action: 'delete', - })).resolves.toEqual({ + })).resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'offline', details: {} }, + error: { code: 'gateway/internal', message: 'offline' }, }) }) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index c1c7bb99f8..51d59f1732 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamTaskId, TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { TeamAction, type TeamActionInjected, type TeamActionProps, type TeamActionResult, @@ -64,8 +64,8 @@ function taskRejected(message: string): TeamTaskActionResult { } } -function remoteFailure(message: string): { ok: false; error: { code: 'internal'; message: string; details: {} } } { - return { ok: false, error: { code: 'internal', message, details: {} } } +function remoteFailure(message: string): TeamActionResult { + return { ok: false, error: new RemoteError('gateway/internal', message, {}) } } function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps { @@ -443,7 +443,7 @@ describe('TeamAction', () => { fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) await screen.findByText('Implement runtime') fireEvent.click(screen.getByRole('button', { name: /完成/u })) - expect(await screen.findByText('task reload failed (internal)')).toBeTruthy() + expect(await screen.findByText('task reload failed (gateway/internal)')).toBeTruthy() expect(screen.queryByText(zh.conflict)).toBeNull() first.unmount() @@ -461,7 +461,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Edited' } }) fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('dependency reload failed (internal)')).toBeTruthy() + expect(await screen.findByText('dependency reload failed (gateway/internal)')).toBeTruthy() expect(screen.queryByText(zh.conflict)).toBeNull() }) @@ -521,7 +521,7 @@ describe('TeamAction', () => { }) const first = render() fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('load failed (internal)')).toBeTruthy() + expect(await screen.findByText('load failed (gateway/internal)')).toBeTruthy() first.unmount() const createTask = vi.fn(() => Promise.resolve(remoteFailure('create failed'))) @@ -532,7 +532,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Task' } }) fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Description' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('create failed (internal)')).toBeTruthy() + expect(await screen.findByText('create failed (gateway/internal)')).toBeTruthy() second.unmount() const pending = Promise.withResolvers() @@ -632,7 +632,7 @@ describe('TeamAction', () => { expect(screen.queryByRole('button', { name: '保存' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: /编辑/u })) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('edit failed (internal)')).toBeTruthy() + expect(await screen.findByText('edit failed (gateway/internal)')).toBeTruthy() fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Saved edit' } }) fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) @@ -666,7 +666,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('dependency transport failed (internal)')).toBeTruthy() + expect(await screen.findByText('dependency transport failed (gateway/internal)')).toBeTruthy() }) it('skips the dependency mutation when an edit keeps the same blockers', async () => { diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index 64e550dc48..bf97dbffa3 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -196,7 +196,7 @@ function stubWorker(): { id: 1, failure: { kind: 'remote', - code: 'session-not-found', + code: 'session/not-found', message: 'fixture Session is absent', details: { sessionId: 'session-1' }, }, @@ -211,7 +211,7 @@ function stubWorker(): { }, { message: 'fixture Session is absent', dshRemoteStreamFailure: { - kind: 'remote', code: 'session-not-found', details: { sessionId: 'session-1' }, + kind: 'remote', code: 'session/not-found', details: { sessionId: 'session-1' }, }, }) } diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 6c3e49abed..a7c39b524e 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -48,7 +48,6 @@ "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", @@ -58,7 +57,6 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts b/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts index 729278a498..bb21d74edf 100644 --- a/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts +++ b/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts @@ -3,9 +3,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { CordisInspectProviderManifest, CordisInspectQueryRequest, CordisInspectQueryResolution, - CordisInspectRequestId, JsonValue, + CordisInspectRequestId, JsonValue, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Context supplied to a Client inspect provider query. */ export interface ClientCordisInspectQueryContext { diff --git a/packages/extensions/cordis-client-runner/src/client/orchestrator.ts b/packages/extensions/cordis-client-runner/src/client/orchestrator.ts index b0b713f014..604fc59d83 100644 --- a/packages/extensions/cordis-client-runner/src/client/orchestrator.ts +++ b/packages/extensions/cordis-client-runner/src/client/orchestrator.ts @@ -17,8 +17,8 @@ import type { DynamicCordisResolveAck, DynamicCordisRunResolution, DynamicCordisRunResponse, + SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { errorDetails } from './runtime.ts' import type { CordisErrorDetails, CordisObservable, DynamicCordisPackageRunner } from './runtime.ts' diff --git a/packages/extensions/cordis-client-runner/src/client/runtime.ts b/packages/extensions/cordis-client-runner/src/client/runtime.ts index f89d2220af..c16e420ffb 100644 --- a/packages/extensions/cordis-client-runner/src/client/runtime.ts +++ b/packages/extensions/cordis-client-runner/src/client/runtime.ts @@ -18,8 +18,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { Loader } from '@deepseek-ai/cordis-plugin-loader' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, DynamicCordisPackage, + SessionId, } 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-ui-renderer/client' import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts' diff --git a/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts b/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts index 7f3ba4eb19..9b9ed0ccc5 100644 --- a/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts @@ -10,9 +10,8 @@ import { describe, expect, it, vi } from 'vitest' import type { ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, - DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck, + DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { CordisRunOrchestrator } from '../src/client/orchestrator.ts' import type { CordisUserRunRequest } from '../src/client/orchestrator.ts' import type { DynamicCordisLoadResult, DynamicCordisPackageRunner } from '../src/client/runtime.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 4144d97bd0..008067e4ab 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -13,9 +13,8 @@ import { describe, expect, it, vi } from 'vitest' import InvariantService from '@deepseek-ai/dsh-invariants' import type { ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, + DynamicCordisInvokeResult, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -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-ui-renderer/client' 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 02eac311df..93cd0d3373 100644 --- a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts @@ -15,9 +15,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Loader } from '@deepseek-ai/cordis-plugin-loader' import { describe, expect, it, vi } from 'vitest' import type { - CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, + CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId, } 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-ui-renderer/client' import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts' diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index f1ac2c60ca..8f34d66195 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../api/remotes/tsconfig.client.json" }, - { - "path": "../../client/connection/tsconfig.client.json" - }, { "path": "../../client/modules" }, diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx index 4e96d15c94..490c37e803 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx @@ -9,7 +9,7 @@ import { import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { CordisRunActivity } from '@deepseek-ai/dsh-cordis-client-runner/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { CordisInventoryRow } from './dynamic-port.ts' import type { CordisPanelFace } from './slots.ts' import type { CordisKey } from './locales.ts' diff --git a/packages/extensions/ui-cordis/src/client/dynamic-port.ts b/packages/extensions/ui-cordis/src/client/dynamic-port.ts index 346346b153..fcc820263b 100644 --- a/packages/extensions/ui-cordis/src/client/dynamic-port.ts +++ b/packages/extensions/ui-cordis/src/client/dynamic-port.ts @@ -1,6 +1,6 @@ /** Host operations used directly by the frame-wide Cordis panel. */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { CordisDynamicPluginId, DynamicCordisInventoryRow, } from './events.ts' diff --git a/packages/extensions/ui-cordis/src/client/run-card-index.ts b/packages/extensions/ui-cordis/src/client/run-card-index.ts index df9b63db9a..50d82b5677 100644 --- a/packages/extensions/ui-cordis/src/client/run-card-index.ts +++ b/packages/extensions/ui-cordis/src/client/run-card-index.ts @@ -1,6 +1,6 @@ /** Session-local ownership index for Package business views on `cordis_run` cards. */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, diff --git a/packages/extensions/ui-cordis/src/client/slots.ts b/packages/extensions/ui-cordis/src/client/slots.ts index 42b2c987a0..51fe163159 100644 --- a/packages/extensions/ui-cordis/src/client/slots.ts +++ b/packages/extensions/ui-cordis/src/client/slots.ts @@ -1,6 +1,6 @@ /** Injected faces and the Package-owned `tool.view.cordis` slot declaration. */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest, DynamicCordisLivePackage, diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 37b6795a13..c862fa2352 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { GenerateOptions, LlmConfigurableProvider, @@ -615,7 +615,7 @@ export class LlmRuntime extends TypertRemoteService { * @param request - endpoint, protocol, and one-shot credential to use. * @param signal - caller cancellation supplied by the Remote carrier. * @returns advertised models in endpoint order. - * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + * @throws RemoteError with `llm/model-discovery-rejected` when discovery refuses or fails. */ @Remote('discoverModels') async remoteDiscoverModels( @@ -626,14 +626,15 @@ export class LlmRuntime extends TypertRemoteService { try { return await this.discoverModels(settingsNs, request, signal) } catch (error: unknown) { - throw new TypertRemoteFailure({ - code: 'model-discovery-failed', - message: error instanceof Error ? error.message : String(error), - details: { + throw new RemoteError( + 'llm/model-discovery-rejected', + error instanceof Error ? error.message : String(error), + { settingsNs, ...request.baseURL === undefined ? {} : { baseURL: request.baseURL }, }, - }) + { cause: error }, + ) } } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 438fcaa67b..4303538892 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -255,13 +255,13 @@ export interface LlmModelDiscoveryOperation extends LlmModelDiscoveryRequest { signal?: AbortSignal } -/** Stable failure returned by the `llm/discoverModels` Remote method. */ -export interface LlmModelDiscoveryError { - readonly code: 'model-discovery-failed' - readonly message: string - readonly details: { - readonly settingsNs: string - readonly baseURL?: string +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** A draft provider interrogation refused or failed. */ + 'llm/model-discovery-rejected': { + readonly settingsNs: string + readonly baseURL?: string + } } } diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 0fbe8255e6..e647b4052b 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -283,22 +283,18 @@ describe('model discovery registry', () => { { baseURL: 'https://gateway.example/v1' }, signal, )).rejects.toMatchObject({ - failure: { - code: 'model-discovery-failed', - message: 'endpoint offline', - details: { settingsNs: 'llm-example', baseURL: 'https://gateway.example/v1' }, - }, + code: 'llm/model-discovery-rejected', + message: 'endpoint offline', + details: { settingsNs: 'llm-example', baseURL: 'https://gateway.example/v1' }, }) await expect(ctx.llm.remoteDiscoverModels( 'llm-example', { provider: 'known-route' }, signal, )).rejects.toMatchObject({ - failure: { - code: 'model-discovery-failed', - message: 'provider refused', - details: { settingsNs: 'llm-example' }, - }, + code: 'llm/model-discovery-rejected', + message: 'provider refused', + details: { settingsNs: 'llm-example' }, }) }) diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 729e6f68a3..5053f2aff3 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -16,56 +16,47 @@ import { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { expandHomePath } from '@deepseek-ai/dsh-home-paths' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' -/** A preset id that cannot be used as a directory name under a root. */ -export class InvalidPresetIdError extends Error { - constructor( - /** The rejected id. */ - readonly presetId: string, - ) { - super( - `agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — ` - + 'the id is a directory name, so anything else could escape the preset root', - ) - } +/** + * Refuse one authoring request the deployment does not allow. + * @param presetId - what the caller tried to change, for the diagnostic. + * @param reason - why authoring is refused. + * @returns the failure to throw. + */ +function notWritable(presetId: string, reason: string): RemoteError<'agent-preset/read-only'> { + return new RemoteError( + 'agent-preset/read-only', + `agent-presets: preset "${presetId}" cannot be written: ${reason}`, + { agentPreset: presetId, reason }, + ) } -/** A copy target that is already occupied — a copy never overwrites. */ -export class PresetExistsError extends Error { - constructor( - /** The id that is already taken. */ - readonly presetId: string, - ) { - super( - `agent-presets: preset "${presetId}" already exists — ` - + 'a copy never overwrites; delete the existing preset first or choose another id', - ) - } -} - -/** Authoring was attempted where the deployment allows none. */ -export class PresetNotWritableError extends Error { - constructor( - /** What the caller tried to change, for the diagnostic. */ - readonly presetId: string, - reason: string, - ) { - super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`) - } +/** + * Refuse a copy onto an id something already occupies. Both the roster check + * and the on-disk check answer with it, so a taken id reads the same either way. + * @param presetId - the id that is already taken. + * @returns the failure to throw. + */ +export function presetExists(presetId: string): RemoteError<'agent-preset/invalid'> { + const reason = `preset "${presetId}" already exists — ` + + 'a copy never overwrites; delete the existing preset first or choose another id' + return new RemoteError('agent-preset/invalid', `agent-presets: ${reason}`, { agentPreset: presetId, reason }) } /** * The root locally authored presets are written to. * @param roots - the configured roots in precedence order. + * @param presetId - the preset the caller is authoring, named by the refusal. * @returns the absolute path of the first `user` root. * @throws when the deployment configured no writable root. */ -export function writableRoot(roots: readonly PresetRoot[]): string { +export function writableRoot(roots: readonly PresetRoot[], presetId: string): string { const root = roots.find(candidate => candidate.trust === 'user') if (root === undefined) { - throw new PresetNotWritableError('', 'this deployment configures no user-writable preset root') + throw notWritable(presetId, 'this deployment configures no user-writable preset root') } return resolve(expandHomePath(root.path)) } @@ -139,12 +130,16 @@ export async function copyComposition( id: string, name?: string, ): Promise { - if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id) - const dir = join(writableRoot(roots), id) + if (!PRESET_ID.test(id)) { + const reason = `preset id ${JSON.stringify(id)} must match ${String(PRESET_ID)} — ` + + 'the id is a directory name, so anything else could escape the preset root' + throw new RemoteError('agent-preset/invalid', `agent-presets: ${reason}`, { agentPreset: id, reason }) + } + const dir = join(writableRoot(roots, id), id) // The roster check upstream only sees discovered presets; a directory with // no composition file still occupies the name and deserves a readable // refusal rather than a filesystem error code. - if (await occupied(dir)) throw new PresetExistsError(id) + if (await occupied(dir)) throw presetExists(id) try { await cp(dirname(source.path), dir, { recursive: true, dereference: true, force: false, errorOnExist: true, @@ -184,13 +179,13 @@ export async function deleteComposition( preset: AgentPreset, ): Promise { if (preset.trust !== 'user') { - throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + throw notWritable(preset.id, 'it ships with the deployment') } - const dir = join(writableRoot(roots), preset.id) + const dir = join(writableRoot(roots, preset.id), preset.id) // Belt and braces over the id pattern: the resolved directory must still be // the one the writable root owns, whatever discovery reported. if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) { - throw new PresetNotWritableError(preset.id, 'it does not live under the writable preset root') + throw notWritable(preset.id, 'it does not live under the writable preset root') } await rm(dir, { recursive: true, force: true }) } diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 160e34cfa8..cc568fc6e8 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -24,94 +24,34 @@ import { stat } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentPresetDocument, AgentPresetErrorDetailsMap, AgentPresetRoster } from './types.ts' +import type { AgentPresetDocument, AgentPresetRoster } from './types.ts' import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves the registry notification emitted after scope reparenting. import type {} from '@deepseek-ai/dsh-tools' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' -import { - copyComposition, deleteComposition, readComposition, - InvalidPresetIdError, PresetExistsError, PresetNotWritableError, -} from './authoring.ts' +import { copyComposition, deleteComposition, presetExists, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' -import { - PresetLockedError, PresetMountError, UnknownPresetError, - type AgentPreset, type Config, type PresetRoot, -} from './preset.ts' +import type { AgentPreset, Config, PresetRoot } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' export type * from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' -/** Construct one typed preset failure for the Remote carrier. */ -function remotePresetFailure( - code: Code, - message: string, - details: AgentPresetErrorDetailsMap[Code], -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} - -/** Map one preset rejection to its stable Remote code and details. */ -function presetFailure(error: unknown, agentPreset: string): TypertRemoteFailure | undefined { - if (error instanceof UnknownPresetError) { - return remotePresetFailure( - 'agent-preset-not-found', - error.message, - { agentPreset: error.presetId, available: [...error.available] }, - ) - } - if (error instanceof PresetMountError) { - return remotePresetFailure( - 'agent-preset-invalid', - error.message, - { agentPreset: error.presetId, reason: error.reason }, - ) - } - if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { - return remotePresetFailure( - 'agent-preset-invalid', - error.message, - { agentPreset: error.presetId, reason: error.message }, - ) - } - if (error instanceof PresetNotWritableError) { - return remotePresetFailure( - 'agent-preset-read-only', - error.message, - { agentPreset, reason: error.message }, - ) - } - if (error instanceof PresetLockedError) { - return remotePresetFailure( - 'agent-preset-locked', - `session "${error.sessionId}" has already started; its agent preset is fixed`, - { sessionId: error.sessionId, agentPreset: error.presetId }, - ) - } - return undefined -} - /** Refuse an empty preset id before invoking a domain operation. */ function validatePresetId(value: string, field: 'agentPreset' | 'from'): void { if (value.length === 0) { - throw remotePresetFailure('bad-request', `${field} must be a non-empty string`, {}) + throw new RemoteError('gateway/bad-request', `${field} must be a non-empty string`, {}) } } -/** Throw the stable preset failure or the caller's operation-specific fallback. */ -function rejectPreset(error: unknown, agentPreset: string, fallbackMessage: string): never { - throw presetFailure(error, agentPreset) ?? remotePresetFailure('internal', fallbackMessage, {}) -} - /** The user-writable slice of this plugin's config. */ export interface AgentPresetSettings { /** Preset mounted when a session names none. */ @@ -131,12 +71,8 @@ export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, type JoinedPresetMount, type PresetMount, } from './mount.ts' -export { - copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, - PresetNotWritableError, readComposition, writableRoot, -} from './authoring.ts' +export { copyComposition, deleteComposition, readComposition, writableRoot } from './authoring.ts' export { agentPresetProjectionDefinition } from './session.ts' -export { PresetLockedError, PresetMountError, UnknownPresetError } from './preset.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' declare module '@deepseek-ai/cordis' { @@ -343,7 +279,12 @@ export class AgentPresets extends TypertRemoteService { const presets = await this.list() const found = presets.find(preset => preset.id === wanted) if (found === undefined) { - throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) + const available = presets.map(preset => preset.id) + throw new RemoteError( + 'agent-preset/not-found', + `agent-presets: preset "${wanted}" not found (available: ${available.join(', ') || 'none'})`, + { agentPreset: wanted, available }, + ) } return found } @@ -361,7 +302,11 @@ export class AgentPresets extends TypertRemoteService { private async resolveMountable(id?: string): Promise { const preset = await this.resolve(id) if (preset.broken !== undefined) { - throw new PresetMountError(preset.id, preset.broken) + throw new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "${preset.id}" failed to mount: ${preset.broken}`, + { agentPreset: preset.id, reason: preset.broken }, + ) } return preset } @@ -495,23 +440,19 @@ export class AgentPresets extends TypertRemoteService { * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. * @returns the composition beside its trust and published metadata. - * @throws {TypertRemoteFailure} `bad-request` for an empty id, or - * `agent-preset-not-found` when no configured root supplies it. + * @throws {RemoteError} `gateway/bad-request` for an empty id, or + * `agent-preset/not-found` when no configured root supplies it. */ @Remote('read') async readDocument(agentPreset: string): Promise { validatePresetId(agentPreset, 'agentPreset') - try { - const preset = await this.resolve(agentPreset) - return { - agentPreset: preset.id, - trust: preset.trust, - content: await this.read(preset.id), - ...preset.name === undefined ? {} : { name: preset.name }, - ...preset.description === undefined ? {} : { description: preset.description }, - } - } catch (error: unknown) { - rejectPreset(error, agentPreset, `agent preset "${agentPreset}": ${String(error)}`) + const preset = await this.resolve(agentPreset) + return { + agentPreset: preset.id, + trust: preset.trust, + content: await this.read(preset.id), + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, } } @@ -536,7 +477,7 @@ export class AgentPresets extends TypertRemoteService { // since a user directory named like a shipped preset is shadowed by it. // The disk check inside copyComposition only sees the writable root. if ((await this.list()).some(preset => preset.id === id)) { - throw new PresetExistsError(id) + throw presetExists(id) } await copyComposition(this.resolvedRoots, source, id, name) // A settled mount under this id can only be stale (its preset was deleted @@ -551,18 +492,14 @@ export class AgentPresets extends TypertRemoteService { * @param id - the new preset id. * @param name - the copy's optional display name. * @returns once the copy is stored. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when the copy is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when the copy is refused. */ @Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise { validatePresetId(from, 'from') validatePresetId(id, 'agentPreset') - try { - await this.copy(from, id, name) - } catch (error: unknown) { - rejectPreset(error, id, `agent preset "${id}": ${String(error)}`) - } + await this.copy(from, id, name) } /** @@ -593,17 +530,13 @@ export class AgentPresets extends TypertRemoteService { * Delete one preset through the Remote API. * @param id - the preset id. * @returns once the preset is deleted. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when deletion is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when deletion is refused. */ @Remote('deletePreset') async remoteExportDelete(id: string): Promise { validatePresetId(id, 'agentPreset') - try { - await this.remove(id) - } catch (error: unknown) { - rejectPreset(error, id, `agent preset "${id}": ${String(error)}`) - } + await this.remove(id) } /** @@ -689,8 +622,8 @@ export class AgentPresets extends TypertRemoteService { * @param agent - the session's live agent, resolved from the wire identity. * @param agentPreset - the preset to compose the agent from instead. * @returns the preset id that was recorded. - * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, - * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, + * `agent-preset/not-found`, or `agent-preset/invalid` when refused. */ @Remote('select') async select(agent: Agent, agentPreset: string): Promise { @@ -701,8 +634,6 @@ export class AgentPresets extends TypertRemoteService { this.switches.set(agent.id, guard) try { return await turn - } catch (error: unknown) { - return rejectPreset(error, agentPreset, `failed to select agent preset "${agentPreset}": ${String(error)}`) } finally { if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id) } @@ -717,7 +648,11 @@ export class AgentPresets extends TypertRemoteService { const boundary = this.selfCtx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { - throw new PresetLockedError(agent.id, agentPreset) + throw new RemoteError( + 'agent-preset/locked', + `session "${agent.id}" has already started; its agent preset is fixed`, + { sessionId: agent.id, agentPreset }, + ) } const preset = await this.recompose(agent.ctx, agentPreset) // Recorded only after the swap committed: the log states what the agent @@ -774,7 +709,12 @@ export class AgentPresets extends TypertRemoteService { // refreshes instead of trusting a composition older than its stamp. const stamp = await compositionStamp(preset.path) if (stamp === undefined) { - throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`) + const reason = `composition file is unreadable: ${preset.path}` + throw new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "${preset.id}" failed to mount: ${reason}`, + { agentPreset: preset.id, reason }, + ) } await mountPreset(scope.ctx, preset) return { key, scope, stamp } diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index d9a92d6a27..04b037be23 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -19,7 +19,8 @@ import { Context, type Fiber } from '@deepseek-ai/cordis' import { Include } from '@deepseek-ai/cordis-plugin-include' import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' -import { PresetMountError, type AgentPreset } from './preset.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { AgentPreset } from './preset.ts' import { classifyRowSpecifier } from './specifier.ts' /** What one mounted subtree publishes about itself for the audit to read. */ @@ -406,6 +407,12 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi // Swallows only this subtree's teardown failure. The mount error below is // the actionable one, and the discarded fiber is unreachable either way. } - throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error }) + const reason = `${mountDetail(error)} (${preset.path})` + throw new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "${preset.id}" failed to mount: ${reason}`, + { agentPreset: preset.id, reason }, + { cause: error }, + ) } } diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index bb0dd18496..5ffa58aee4 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -1,7 +1,5 @@ /** Agent-preset vocabulary shared by discovery, mounting, and consumers. */ -import type { SessionId } from '@deepseek-ai/dsh-session/types' - /** * Where a preset's composition came from. A `system` preset ships with the * deployment; a `user` preset was authored locally, by a person or by an @@ -70,50 +68,3 @@ export interface Config { */ includeUserRoot: boolean } - -/** - * No configured root supplies the requested preset. - * - * Separate from a mount failure because the two mean different things to a - * caller: an unknown id is a bad request, while an unusable composition is a - * broken preset the deployment must fix. - */ -export class UnknownPresetError extends Error { - constructor( - /** The id that was requested. */ - readonly presetId: string, - /** Ids the roster does supply, for the caller to offer instead. */ - readonly available: readonly string[], - ) { - super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) - } -} - -/** - * The session's composition is fixed: its conversation has started, so its - * history was produced under the preset it runs and swapping the composition - * would leave logged tool calls the new one cannot make. - */ -export class PresetLockedError extends Error { - constructor( - /** The session whose composition is already fixed. */ - readonly sessionId: SessionId, - /** The preset that was refused. */ - readonly presetId: string, - ) { - super(`agent-presets: session "${sessionId}" has already started; its agent preset is fixed`) - } -} - -/** A preset exists but its composition cannot be installed. */ -export class PresetMountError extends Error { - constructor( - /** The preset whose composition failed. */ - readonly presetId: string, - /** Why it failed, without this package's own message prefix. */ - readonly reason: string, - options?: ErrorOptions, - ) { - super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) - } -} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index fc567f9ad5..dde42cfaf3 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -31,30 +31,18 @@ export interface AgentPresetRoster { readonly authorable: boolean } -/** Stable details for agent-preset failures returned by the Remote namespace. */ -export interface AgentPresetErrorDetailsMap { - /** A required preset id is empty. */ - 'bad-request': Record - /** No configured root supplies the requested id. */ - 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } - /** The id is unusable, already taken, or its composition cannot be installed. */ - 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } - /** The preset ships with the deployment and is not the user's to change. */ - 'agent-preset-read-only': { readonly agentPreset: string; readonly reason: string } - /** The session's conversation has started, so its composition is fixed. */ - 'agent-preset-locked': { readonly sessionId: SessionId; readonly agentPreset: string } - /** The preset operation failed without a caller-actionable classification. */ - internal: Record -} - -/** One agent-preset refusal as a client reads it. */ -export type AgentPresetError = { - [Code in keyof AgentPresetErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: AgentPresetErrorDetailsMap[Code] +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No configured root supplies the requested id. */ + 'agent-preset/not-found': { readonly agentPreset: string; readonly available: readonly string[] } + /** The id is unusable, already taken, or its composition cannot be installed. */ + 'agent-preset/invalid': { readonly agentPreset: string; readonly reason: string } + /** The preset ships with the deployment and is not the user's to change. */ + 'agent-preset/read-only': { readonly agentPreset: string; readonly reason: string } + /** The session's conversation has started, so its composition is fixed. */ + 'agent-preset/locked': { readonly sessionId: SessionId; readonly agentPreset: string } } -}[keyof AgentPresetErrorDetailsMap] +} /** One preset's composition text beside the row it belongs to. */ export interface AgentPresetDocument { diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 6695450a3a..9d5d052724 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -15,7 +15,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh- import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { - COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, + COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-agent-presets/types' @@ -369,9 +369,10 @@ describe('composing from a broken preset', () => { const scoped = await rosterWith('- id: x\n name: [unclosed\n') // The refusal happens before the loader ever sees the file, so every - // unloadable shape gets the same early PresetMountError — and a rejected - // setup rolls the whole agent creation back. - await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError) + // unloadable shape gets the same early agent-preset/invalid — and a + // rejected setup rolls the whole agent creation back. + await expect(agentOn(scoped, 'sess-broken', 'damaged')) + .rejects.toMatchObject({ code: 'agent-preset/invalid' }) await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/) expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0) }) @@ -769,7 +770,7 @@ describe('editing a composition file', () => { ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise } await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path })) - .rejects.toThrow(PresetMountError) + .rejects.toMatchObject({ code: 'agent-preset/invalid' }) expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0) }) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index b7ece833cc..9a9992e3d3 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -18,7 +18,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { TypertRemoteFailure, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' @@ -40,9 +40,9 @@ async function remoteFailure(operation: Promise): Promise { const resolve = vi.spyOn(ctx.agentPresets, 'resolve') await expect(ctx.agentPresets.readDocument('')) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) expect(resolve).not.toHaveBeenCalled() }) @@ -203,7 +203,7 @@ describe('reading one composition', () => { const failure = await remoteFailure(ctx.agentPresets.readDocument('never-existed')) expect(failure).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'never-existed', }, @@ -213,17 +213,12 @@ describe('reading one composition', () => { expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) }) - it('keeps the legacy internal diagnostic for an unrelated read failure', async () => { + it('raises an unrelated read failure exactly as it was thrown', async () => { const ctx = await harness() - vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(new Error('disk failed')) + const thrown = new Error('disk failed') + vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(thrown) - const failure = await remoteFailure(ctx.agentPresets.readDocument('standard')) - - expect(failure).toEqual({ - code: 'internal', - message: 'agent preset "standard": Error: disk failed', - details: {}, - }) + await expect(ctx.agentPresets.readDocument('standard')).rejects.toBe(thrown) }) }) @@ -238,7 +233,7 @@ describe('authoring over Remote', () => { () => ctx.agentPresets.remoteExportCopy('standard', ''), () => ctx.agentPresets.remoteExportDelete(''), ]) { - await expect(operation()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await expect(operation()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } expect(copy).not.toHaveBeenCalled() expect(remove).not.toHaveBeenCalled() @@ -266,7 +261,7 @@ describe('authoring over Remote', () => { const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('never-existed', 'mine')) expect(failure).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'never-existed', }, @@ -281,14 +276,14 @@ describe('authoring over Remote', () => { const invalid = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', '../escape')) expect(invalid).toMatchObject({ - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', details: { agentPreset: '../escape' }, }) expect(reasonOf(invalid)).toContain('must match') const occupied = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'minimal')) expect(occupied).toMatchObject({ - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', details: { agentPreset: 'minimal' }, }) expect(reasonOf(occupied)).toContain('already exists') @@ -305,7 +300,7 @@ describe('authoring over Remote', () => { const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine')) expect(failure).toMatchObject({ - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', details: { agentPreset: 'mine' }, }) expect(reasonOf(failure)).toContain('no user-writable preset root') @@ -316,30 +311,25 @@ describe('authoring over Remote', () => { const readOnly = await remoteFailure(ctx.agentPresets.remoteExportDelete('standard')) expect(readOnly).toMatchObject({ - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', details: { agentPreset: 'standard' }, }) expect(reasonOf(readOnly)).toContain('ships with the deployment') const missing = await remoteFailure(ctx.agentPresets.remoteExportDelete('never-existed')) expect(missing).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'never-existed' }, }) expect(availableOf(missing)).toEqual(expect.arrayContaining(['minimal', 'standard'])) }) - it('keeps the legacy internal diagnostic for an unrelated authoring failure', async () => { + it('raises an unrelated authoring failure exactly as it was thrown', async () => { const ctx = await harness() - vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(new Error('copy failed')) + const thrown = new Error('copy failed') + vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(thrown) - const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine')) - - expect(failure).toEqual({ - code: 'internal', - message: 'agent preset "mine": Error: copy failed', - details: {}, - }) + await expect(ctx.agentPresets.remoteExportCopy('standard', 'mine')).rejects.toBe(thrown) }) }) @@ -350,7 +340,7 @@ describe('switching one session\'s composition', () => { const recompose = vi.spyOn(ctx.agentPresets, 'recompose') await expect(ctx.agentPresets.select(agent, '')) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) expect(recompose).not.toHaveBeenCalled() }) @@ -403,8 +393,8 @@ describe('switching one session\'s composition', () => { const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal')) - expect(failure).toEqual({ - code: 'agent-preset-locked', + expect(failure).toMatchObject({ + code: 'agent-preset/locked', message: 'session "sel-locked" has already started; its agent preset is fixed', details: { sessionId: SessionId('sel-locked'), agentPreset: 'minimal' }, }) @@ -419,7 +409,7 @@ describe('switching one session\'s composition', () => { const failure = await remoteFailure(ctx.agentPresets.select(agent, 'nope')) expect(failure).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'nope' }, }) expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) @@ -454,23 +444,18 @@ describe('switching one session\'s composition', () => { const failure = await remoteFailure(ctx.agentPresets.select(agent, 'damaged')) expect(failure).toMatchObject({ - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', details: { agentPreset: 'damaged' }, }) expect(reasonOf(failure)).not.toBe('') }) - it('keeps the legacy internal diagnostic for an unrelated switch failure', async () => { + it('raises an unrelated switch failure exactly as it was thrown', async () => { const ctx = await harness() const agent = await agentOn(ctx, 'sel-internal', 'standard') - vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(new Error('mount failed')) + const thrown = new Error('mount failed') + vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(thrown) - const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal')) - - expect(failure).toEqual({ - code: 'internal', - message: 'failed to select agent preset "minimal": Error: mount failed', - details: {}, - }) + await expect(ctx.agentPresets.select(agent, 'minimal')).rejects.toBe(thrown) }) }) diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 425c7b9b03..d7c86cca06 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -54,6 +54,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -67,6 +68,7 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", @@ -104,6 +106,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -120,6 +123,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index ea02674413..d40aa1d9f4 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -6,11 +6,11 @@ * @module @deepseek-ai/dsh-subagent/control-types */ +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { z as zCore } from 'zod' /** * Client-minted identity of one browser prompt, persisted on the exact accepted @@ -95,6 +95,21 @@ export type SubagentAddress = | { readonly mode: 'continuable' } ) +/** + * One browser-encoded upload as the Session prompt wire carries it: the shared + * attachment vocabulary under the content-block tag. + */ +export interface EncodedImagePromptBlock extends EncodedImageAttachment { + readonly type: 'image' +} + +/** + * One block a browser prompt may carry. The encoded upload is accepted by the + * wire and refused by the Host, so the Client narrows nothing: a caller that + * attaches an image is answered, not silently stripped. + */ +export type SubagentPromptContentPart = ContentBlock | EncodedImagePromptBlock + /** One human message addressed to a continuable direct child. */ export interface SubagentPromptRequest { /** Identity persisted on the accepted message, minted before the call. */ @@ -103,8 +118,8 @@ export interface SubagentPromptRequest { readonly childSessionId: SessionId /** Required discriminator retained from the browser control address. */ readonly mode: 'continuable' - /** Content delivered as the child's user message. */ - readonly content: ContentBlock[] + /** Content proposed as the child's user message; images are refused. */ + readonly content: readonly SubagentPromptContentPart[] /** Optional browser zone sampled for this exact human prompt. */ readonly clientTimeZone?: string } @@ -122,26 +137,27 @@ export interface SubagentInterruptReceipt { /** * Failure details the control surface answers with. The catalog read, the * prompt, and the interrupt produce these codes; a Client fabricates - * `subagent-not-resumable` and `subagent-delivery-unavailable` for a one-shot + * `subagent/not-resumable` and `subagent/delivery-unavailable` for a one-shot * address it refuses before the call, so both planes read one vocabulary. */ -export interface SubagentControlErrorDetailsMap { - 'bad-request': { readonly issues: zCore.core.$ZodIssue[] } - cancelled: Record - 'invalid-time-zone': { readonly value: string } - 'subagent-parent-unavailable': { readonly parentSessionId: SessionId } - 'subagent-not-resumable': { readonly childSessionId: SessionId } - 'subagent-unauthorized': { readonly childSessionId: SessionId } - 'subagent-delivery-unavailable': { readonly childSessionId: SessionId } - 'subagent-projections-unavailable': Record - internal: Record -} - -/** One subagent control failure, returned without a carrier error. */ -export type SubagentControlError = { - [Code in keyof SubagentControlErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: SubagentControlErrorDetailsMap[Code] +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** A browser-supplied zone is neither UTC nor a canonical IANA name. */ + 'subagent/invalid-time-zone': { readonly value: string } + /** No live Agent carries the addressed parent session. */ + 'subagent/parent-unavailable': { readonly parentSessionId: SessionId } + /** The addressed child cannot take a continuation. */ + 'subagent/not-resumable': { readonly childSessionId: SessionId } + /** The claimed parent does not own the addressed child. */ + 'subagent/unauthorized': { readonly childSessionId: SessionId } + /** + * The continuation admits no attachment. `reason` names the refused plane + * for the caller's copy, as the Session prompt's attachment refusals do. + */ + 'subagent/attachment-unsupported': { readonly childSessionId: SessionId; readonly reason: string } + /** The child exists but its inbox cannot admit the message now. */ + 'subagent/delivery-unavailable': { readonly childSessionId: SessionId } + /** The deployment mounts no session-projection registry. */ + 'subagent/projections-unavailable': {} } -}[keyof SubagentControlErrorDetailsMap] +} diff --git a/packages/subagent/subagent/src/control.ts b/packages/subagent/subagent/src/control.ts index 661a43153a..d3e68aeb39 100644 --- a/packages/subagent/subagent/src/control.ts +++ b/packages/subagent/subagent/src/control.ts @@ -7,17 +7,15 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import type { - SubagentCatalog, SubagentControlErrorDetailsMap, SubagentListEntry, + SubagentCatalog, SubagentListEntry, SubagentPromptContentPart, } from './control-types.ts' import { SubagentError } from './error.ts' -/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - const SESSION_ID_SCHEMA = z.string().min(1) const CONTROL_ID_SCHEMAS = { 'subagent.list': z.object({ parentSessionId: SESSION_ID_SCHEMA }), @@ -33,48 +31,12 @@ const CONTROL_ID_SCHEMAS = { }), } as const -/** - * Validate and canonicalize one browser-supplied IANA zone at the wire boundary. - * @param value - the browser's reported zone name. - * @returns the canonical zone, or `undefined` when the name is unusable. - */ -export function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) - .resolvedOptions().timeZone - /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ - if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined - return canonical - } catch { - // Intl rejects unsupported zone names; the caller maps that parser rejection. - return undefined - } -} - -/** - * Refuse one Remote call with a stable business failure the carrier preserves. - * @param code - declared caller-facing code. - * @param message - human-readable refusal. - * @param details - that code's declared detail payload. - * @returns Never — the failure is thrown. - * @throws {TypertRemoteFailure} always. - */ -export function rejectControl( - code: Code, - message: string, - details: SubagentControlErrorDetailsMap[Code], -): never { - throw new TypertRemoteFailure({ code, message, details }) -} - /** * Apply the subagent payload checks that are stricter than generated * branded-string codecs. * @param method - method name carried in the failure message. * @param payload - decoded control fields to validate. - * @throws {TypertRemoteFailure} `bad-request` with the original Zod issues. + * @throws {RemoteError} `gateway/bad-request` with the original Zod issues. */ export function validateControlRequest( method: keyof typeof CONTROL_ID_SCHEMAS, @@ -82,12 +44,42 @@ export function validateControlRequest( ): void { const parsed = CONTROL_ID_SCHEMAS[method].safeParse(payload) if (!parsed.success) { - return rejectControl('bad-request', `invalid payload for ${method}`, { - issues: parsed.error.issues, - }) + throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues }) } } +/** + * Admit the content one continuation may deliver, refusing every image. + * + * The blocks become the child's user message verbatim, and this surface admits + * no attachment: nothing here registers encoded bytes with the attachment + * service, so an image would reach the child as a reference nothing resolves. + * The wire accepts the encoded upload so this refusal — not a Client that + * strips the block — is what the caller is answered with. Other block types + * still cross unnarrowed. + * @param childSessionId - the addressed child, named by the refusal. + * @param content - blocks the caller asked to deliver. + * @returns the admitted blocks, in order, as the durable content vocabulary. + * @throws {RemoteError} `subagent/attachment-unsupported` when any block is an image. + */ +export function admitPromptContent( + childSessionId: SessionId, + content: readonly SubagentPromptContentPart[], +): ContentBlock[] { + const admitted: ContentBlock[] = [] + for (const block of content) { + if (block.type === 'image') { + throw new RemoteError( + 'subagent/attachment-unsupported', + 'subagent continuation does not accept images', + { childSessionId, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + ) + } + admitted.push(block) + } + return admitted +} + /** * Project one durable listing onto the catalog view, replacing each row's * store-derived activity with the live Agent driver's status and reporting @@ -118,20 +110,21 @@ export function catalogView( * @param error - the thrown value. * @param signal - the caller's cancellation. * @returns Never — the refusal is thrown. - * @throws {TypertRemoteFailure} always. + * @throws {RemoteError} always. */ export function rejectCatalogRead(error: unknown, signal: AbortSignal): never { if (isCancellation(error, signal)) { - return rejectControl('cancelled', 'subagent catalog read was cancelled', {}) + throw new RemoteError('gateway/cancelled', 'subagent catalog read was cancelled', {}, { cause: error }) } if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return rejectControl( - 'subagent-projections-unavailable', + throw new RemoteError( + 'subagent/projections-unavailable', 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', {}, + { cause: error }, ) } - return rejectControl('internal', 'subagent catalog read failed', {}) + throw new RemoteError('gateway/internal', 'subagent catalog read failed', {}, { cause: error }) } /** @@ -142,37 +135,44 @@ export function rejectCatalogRead(error: unknown, signal: AbortSignal): never { * @param childSessionId - the addressed child. * @param signal - the caller's cancellation. * @returns Never — the refusal is thrown. - * @throws {TypertRemoteFailure} always. + * @throws {RemoteError} always. */ export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: AbortSignal): never { if (isCancellation(error, signal)) { - return rejectControl('cancelled', 'subagent prompt was cancelled', {}) + throw new RemoteError('gateway/cancelled', 'subagent prompt was cancelled', {}, { cause: error }) } if (error instanceof SubagentError) { switch (error.code) { case 'NOT_RESUMABLE': - return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId }) + throw new RemoteError( + 'subagent/not-resumable', + 'subagent cannot be resumed', + { childSessionId }, + { cause: error }, + ) case 'UNAUTHORIZED': - return rejectControl( - 'subagent-unauthorized', + throw new RemoteError( + 'subagent/unauthorized', 'subagent does not belong to this parent', { childSessionId }, + { cause: error }, ) case 'DRAINING': case 'ACTIVATION_CLOSING': case 'CONTINUATION_UNAVAILABLE': case 'PERSISTENCE_UNAVAILABLE': - return rejectControl( - 'subagent-delivery-unavailable', + throw new RemoteError( + 'subagent/delivery-unavailable', 'subagent follow-up is temporarily unavailable', { childSessionId }, + { cause: error }, ) // A code outside the admission vocabulary is not the caller's move to make. default: break } } - return rejectControl('internal', 'subagent prompt failed', {}) + throw new RemoteError('gateway/internal', 'subagent prompt failed', {}, { cause: error }) } function isCancellation(error: unknown, signal: AbortSignal): boolean { diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 444f4ee857..bce712991f 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -36,10 +36,10 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { - canonicalClientTimeZone, catalogView, rejectCatalogRead, rejectControl, rejectPrompt, - validateControlRequest, + admitPromptContent, catalogView, rejectCatalogRead, rejectPrompt, validateControlRequest, } from './control.ts' import type { SubagentCatalog, @@ -398,9 +398,9 @@ export class SubagentRuntime extends TypertRemoteService { * @param parentSessionId - parent session whose direct children are listed. * @param signal - carrier cancellation forwarded to Session queries. * @returns the catalog view for that parent. - * @throws {TypertRemoteFailure} `bad-request` for an empty parent id, - * `cancelled` for an aborted read, `subagent-projections-unavailable` when - * the deployment has no projection registry, otherwise `internal`. + * @throws {RemoteError} `gateway/bad-request` for an empty parent id, + * `gateway/cancelled` for an aborted read, `subagent/projections-unavailable` when + * the deployment has no projection registry, otherwise `gateway/internal`. */ @Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise { @@ -421,29 +421,30 @@ export class SubagentRuntime extends TypertRemoteService { * @param request - durable address, minted identity, content, and optional browser zone. * @param signal - carrier cancellation, owning the call until inbox acceptance. * @returns the accepted message's inbox identity. - * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, - * `subagent-parent-unavailable`, `subagent-not-resumable`, - * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or - * `internal`. + * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-unsupported`, + * `subagent/invalid-time-zone`, `subagent/parent-unavailable`, + * `subagent/not-resumable`, `subagent/unauthorized`, + * `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`. */ @Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise { const { parentSessionId, childSessionId, clientTimeZone } = request validateControlRequest('subagent.prompt', request) + const content = admitPromptContent(childSessionId, request.content) const canonicalTimeZone = clientTimeZone === undefined ? undefined : canonicalClientTimeZone(clientTimeZone) if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { - return rejectControl( - 'invalid-time-zone', + throw new RemoteError( + 'subagent/invalid-time-zone', 'clientTimeZone must be UTC or a valid IANA Area/Location name', { value: clientTimeZone }, ) } const parent = this.ctx.get('agents')?.get(parentSessionId) if (parent === undefined) { - return rejectControl( - 'subagent-parent-unavailable', + throw new RemoteError( + 'subagent/parent-unavailable', `parent session "${parentSessionId}" is not live`, { parentSessionId }, ) @@ -453,7 +454,6 @@ export class SubagentRuntime extends TypertRemoteService { rpcId: request.requestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), } - const content: ContentBlock[] = [...request.content] try { return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } } catch (error: unknown) { @@ -471,9 +471,9 @@ export class SubagentRuntime extends TypertRemoteService { * @param parentSessionId - durable direct parent whose authority is claimed. * @param mode - required continuable-address discriminator. * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. - * @throws {TypertRemoteFailure} `bad-request` for an empty id, - * `subagent-unauthorized` when the address does not own the live target, - * otherwise `internal`. + * @throws {RemoteError} `gateway/bad-request` for an empty id, + * `subagent/unauthorized` when the address does not own the live target, + * otherwise `gateway/internal`. */ @Remote('interruptByParent') interruptByParent( @@ -486,13 +486,14 @@ export class SubagentRuntime extends TypertRemoteService { this.interrupt(childSessionId, { kind: 'user', parentSessionId }) } catch (error: unknown) { if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { - return rejectControl( - 'subagent-unauthorized', + throw new RemoteError( + 'subagent/unauthorized', 'subagent does not belong to this parent', { childSessionId }, + { cause: error }, ) } - return rejectControl('internal', 'subagent interrupt failed', {}) + throw new RemoteError('gateway/internal', 'subagent interrupt failed', {}, { cause: error }) } return { accepted: true } } diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index dd04d549b1..97641511f4 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -47,7 +47,7 @@ function promptRequest(clientTimeZone?: string) { function emptyIdFailure(method: string, field: string) { return { - code: 'bad-request', + code: 'gateway/bad-request', message: `invalid payload for ${method}`, details: { issues: [{ @@ -68,7 +68,7 @@ describe('subagent catalog Remote', () => { const listChildren = vi.spyOn(subagents, 'listChildren') await expect(subagents.remoteExportList(SessionId(''), signal)) - .rejects.toMatchObject({ failure: emptyIdFailure('subagent.list', 'parentSessionId') }) + .rejects.toMatchObject(emptyIdFailure('subagent.list', 'parentSessionId')) expect(listChildren).not.toHaveBeenCalled() }) @@ -117,25 +117,23 @@ describe('subagent catalog Remote', () => { aborted.abort() listChildren.mockRejectedValue(new Error('read stopped')) await expect(subagents.remoteExportList(PARENT, aborted.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) listChildren.mockRejectedValue(new SubagentError('cancelled', 'CANCELLED')) await expect(subagents.remoteExportList(PARENT, signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) listChildren.mockRejectedValue( new SubagentError('no registry', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE'), ) await expect(subagents.remoteExportList(PARENT, signal)).rejects.toMatchObject({ - failure: { - code: 'subagent-projections-unavailable', - message: expect.stringContaining('sessionProjections') as unknown as string, - }, + code: 'subagent/projections-unavailable', + message: expect.stringContaining('sessionProjections') as unknown as string, }) listChildren.mockRejectedValue(new Error('disk gone')) await expect(subagents.remoteExportList(PARENT, signal)) - .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent catalog read failed' } }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'subagent catalog read failed' }) }) }) @@ -153,7 +151,7 @@ describe('subagent prompt Remote', () => { ] for (const { field, request } of cases) { await expect(subagents.prompt(request, signal)) - .rejects.toMatchObject({ failure: emptyIdFailure('subagent.prompt', field) }) + .rejects.toMatchObject(emptyIdFailure('subagent.prompt', field)) } expect(followup).not.toHaveBeenCalled() }) @@ -168,6 +166,23 @@ describe('subagent prompt Remote', () => { expect(followup.mock.calls[0]?.[2]).toEqual(content) }) + it('refuses an image the wire accepts, whatever else the content carries', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup') + // Both variants the widened wire admits: the browser's encoded upload and a + // durable reference. The Client narrows neither, so the Host answers both. + const encoded = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AAA=' } + const durable = { type: 'image' as const, attachment: { attachmentId: 'att-1' } as never } + for (const image of [encoded, durable]) { + const content = [{ type: 'text' as const, text: 'look at this' }, image] + await expect(subagents.prompt({ ...promptRequest(), content }, signal)).rejects.toMatchObject({ + code: 'subagent/attachment-unsupported', + details: { childSessionId: CHILD, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + }) + } + expect(followup).not.toHaveBeenCalled() + }) + it('delivers the content under the caller-minted identity and canonical browser zone', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-1' as MessageId) @@ -200,7 +215,7 @@ describe('subagent prompt Remote', () => { await expect(subagents.prompt(promptRequest('UTC'), signal)).resolves.toEqual({ messageId: 'm-3' }) for (const zone of ['', ' UTC', 'Shanghai', 'Nowhere/Nowhere']) { await expect(subagents.prompt(promptRequest(zone), signal)).rejects.toMatchObject({ - failure: { code: 'invalid-time-zone', details: { value: zone } }, + code: 'subagent/invalid-time-zone', details: { value: zone }, }) } }) @@ -210,7 +225,7 @@ describe('subagent prompt Remote', () => { const followup = vi.spyOn(subagents, 'followup') await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({ - failure: { code: 'subagent-parent-unavailable', details: { parentSessionId: PARENT } }, + code: 'subagent/parent-unavailable', details: { parentSessionId: PARENT }, }) expect(followup).not.toHaveBeenCalled() }) @@ -219,21 +234,21 @@ describe('subagent prompt Remote', () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) const followup = vi.spyOn(subagents, 'followup') const cases: readonly [string, string][] = [ - ['NOT_RESUMABLE', 'subagent-not-resumable'], - ['UNAUTHORIZED', 'subagent-unauthorized'], - ['DRAINING', 'subagent-delivery-unavailable'], - ['ACTIVATION_CLOSING', 'subagent-delivery-unavailable'], - ['NO_PROVIDER', 'internal'], + ['NOT_RESUMABLE', 'subagent/not-resumable'], + ['UNAUTHORIZED', 'subagent/unauthorized'], + ['DRAINING', 'subagent/delivery-unavailable'], + ['ACTIVATION_CLOSING', 'subagent/delivery-unavailable'], + ['NO_PROVIDER', 'gateway/internal'], ] for (const [thrown, code] of cases) { followup.mockRejectedValue(new SubagentError('refused', thrown)) await expect(subagents.prompt(promptRequest(), signal)) - .rejects.toMatchObject({ failure: { code } }) + .rejects.toMatchObject({ code }) } followup.mockRejectedValue(new Error('inbox exploded')) await expect(subagents.prompt(promptRequest(), signal)) - .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent prompt failed' } }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'subagent prompt failed' }) }) it('answers a caller-cancelled delivery as cancelled rather than a failure', async () => { @@ -245,7 +260,7 @@ describe('subagent prompt Remote', () => { }) await expect(subagents.prompt(promptRequest(), aborted.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) it('preserves a cancellation reported by the continuation operation', async () => { @@ -254,7 +269,7 @@ describe('subagent prompt Remote', () => { .mockRejectedValue(new SubagentError('stopped', 'CANCELLED')) await expect(subagents.prompt(promptRequest(), signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) }) @@ -269,9 +284,7 @@ describe('subagent interrupt Remote', () => { ] as const) { const field = childSessionId.length === 0 ? 'childSessionId' : 'parentSessionId' expect(() => subagents.interruptByParent(childSessionId, parentSessionId, 'continuable')) - .toThrow(expect.objectContaining({ - failure: emptyIdFailure('subagent.interrupt', field), - })) + .toThrow(expect.objectContaining(emptyIdFailure('subagent.interrupt', field))) } expect(interrupt).not.toHaveBeenCalled() }) @@ -290,12 +303,16 @@ describe('subagent interrupt Remote', () => { interrupt.mockImplementation(() => { throw new SubagentError('not yours', 'UNAUTHORIZED') }) expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow( - expect.objectContaining({ failure: { code: 'subagent-unauthorized', message: expect.any(String) as unknown as string, details: { childSessionId: CHILD } } }), + expect.objectContaining({ + code: 'subagent/unauthorized', + message: expect.any(String) as unknown as string, + details: { childSessionId: CHILD }, + }), ) interrupt.mockImplementation(() => { throw new Error('boom') }) expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow( - expect.objectContaining({ failure: { code: 'internal', message: 'subagent interrupt failed', details: {} } }), + expect.objectContaining({ code: 'gateway/internal', message: 'subagent interrupt failed', details: {} }), ) }) }) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 4c86dd8250..4a90ca128c 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../core/agent" }, @@ -59,6 +62,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/time" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 427a2a6b2d..43e895f31a 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 6a0b88387f..60c5c078eb 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -45,7 +45,7 @@ export type { StubSettingsScope } from './settings-scope.ts' export { scriptedSettingsRemote } from './settings-remote.ts' export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts' export { TestWorkspaces } from './workspaces.ts' -export { TestRemote } from './remote.ts' +export { RemoteError, TestRemote } from './remote.ts' export { chatSnapshot, conversationSnapshot, sessionSnapshot, workspaceSnapshot, } from './fixtures.ts' diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index 9a0249245c..34737e2295 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -1,6 +1,11 @@ /** Test-owned Remote face: `$on` subscriptions with an explicit test event driver. */ import type { Context } from '@deepseek-ai/cordis' +// Value re-export for spec-side failure construction: the api-remotes facade +// cannot carry it — its src top-level imports owner /remote lib artifacts, so a +// value import from a spec would load the unbuilt assembly chain. +export { RemoteError } from '@deepseek-ai/dsh-typert-protocol' + /** * Remote service test double for the forwarded-event path. Feature specs need * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded @@ -21,6 +26,12 @@ import type { Context } from '@deepseek-ai/cordis' export class TestRemote { private readonly subscriptions = new Map void>>() + /** + * Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain + * mutable field: a spec assigns it to script a non-loopback or homed Host. + */ + $host: { home: string | undefined; isLoopback: boolean } = { home: undefined, isLoopback: true } + /** * Register the double as `ctx.remote`, plus one service per scripted * namespace so a plugin injecting `remote.` also unparks. @@ -31,7 +42,7 @@ export class TestRemote { for (const name of Object.keys(namespaces)) { // A namespace named after one of the double's own members would replace // it, and `$mount`'s rejection is the contract a spec relies on. - if (name in TestRemote.prototype || name === 'subscriptions') { + if (name in TestRemote.prototype || name === 'subscriptions' || name === '$host') { throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`) } } diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts index 0a98a8f621..97c2748480 100644 --- a/packages/test-support/client-runtime/src/settings-remote.ts +++ b/packages/test-support/client-runtime/src/settings-remote.ts @@ -63,7 +63,7 @@ export function scriptedSettingsRemote( return Promise.resolve(view === undefined ? { ok: false as const, - error: { code: 'settings-rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, + error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, } : { ok: true as const, value: view }) } diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index 1cc3e0a372..bb297bb0b6 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -74,7 +74,7 @@ describe('scriptedSettingsRemote', () => { await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first }) await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({ ok: false, - error: { code: 'settings-rejected', details: { ns: 'missing' } }, + error: { code: 'settings/rejected', details: { ns: 'missing' } }, }) await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first }) expect(remote.update).toHaveBeenCalledWith('first', {}, undefined) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index f1e2f9a8d1..ec083971dd 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../typert/protocol" + }, { "path": "../../client/connection/tsconfig.client.json" }, diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index ad8d973f2f..055748d6d3 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -5,7 +5,9 @@ */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { RemoteFailure, TypertContextMap } from './types.ts' +import type { TypertContextMap } from './types.ts' + +export { RemoteError, remoteErrorOf } from './remote-error.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -18,45 +20,12 @@ export function isTypertRemoteSegment(value: string): boolean { return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } -/** - * A lookup policy rejection whose typed payload belongs to the active boundary adapter. - * Gateway adapters preserve this payload instead of collapsing it into an infrastructure failure. - */ -export class TypertLookupFailure extends Error { - /** Adapter-owned failure returned to the caller. */ - readonly failure: Failure - - /** - * Wrap one adapter failure without exposing the rejected identity. - * @param failure - typed failure owned by the active boundary adapter. - */ - constructor(failure: Failure) { - super('Typert lookup policy rejected the requested identity') - this.name = 'TypertLookupFailure' - this.failure = failure - } -} - -/** A business Remote rejection preserved by unary and stream carriers. */ -export class TypertRemoteFailure extends Error { - /** Stable caller-facing failure payload. */ - readonly failure: RemoteFailure - - /** - * Wrap one business rejection for transport without changing its code or details. - * @param failure - business failure returned unchanged to the caller. - */ - constructor(failure: RemoteFailure) { - super(failure.message) - this.name = 'TypertRemoteFailure' - this.failure = failure - } -} - export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + RemoteErrorCode, + RemoteErrorDetailsMap, RemoteFailure, RemoteResult, TypertClientEventListener, diff --git a/packages/typert/protocol/src/remote-error.ts b/packages/typert/protocol/src/remote-error.ts new file mode 100644 index 0000000000..ccdd6cd1d8 --- /dev/null +++ b/packages/typert/protocol/src/remote-error.ts @@ -0,0 +1,46 @@ +/** The one Remote failure class shared by owners, the Gateway, and consumers. */ + +import type { RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure } from './types.ts' + +/** + * One Remote call failure: a real Error carrying its stable code and typed + * details. Owners throw it at the failure point; the Host Gateway encodes it + * onto the wire unchanged; the Client face rebuilds an instance for the + * `RemoteResult` error branch, so `throw result.error` keeps throw semantics. + * Discrimination is always by `code`, never by instanceof. + */ +export class RemoteError extends Error { + /** Structural marker: cross-realm/bundle identification never uses instanceof. */ + readonly isDSHRemoteGatewayError: true = true + + /** + * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}. + * @param message - human diagnostic carried across the wire. + * @param details - structured payload typed by the code. + * @param options - standard Error options (`cause` survives in-process only). + */ + constructor( + readonly code: Code, + message: string, + readonly details: RemoteErrorDetailsMap[Code], + options?: ErrorOptions, + ) { + super(message, options) + this.name = 'RemoteError' + } +} + +/** + * Structurally identify a RemoteError thrown across module or realm copies of + * this class. Mechanism-internal: the Gateway and test assertions use it; + * business code receives typed failures and never needs it. + * @param value - a caught value. + * @returns the failure when the marker matches, otherwise undefined. + */ +export function remoteErrorOf(value: unknown): RemoteFailure | undefined { + if (value instanceof Error + && (value as { isDSHRemoteGatewayError?: unknown }).isDSHRemoteGatewayError === true) { + return value as unknown as RemoteFailure + } + return undefined +} diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 8123b2a36a..2911631ce7 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -40,16 +40,30 @@ export interface TypertContextMap {} export interface TypertRemoteMap {} /** - * One Remote call's failure as the carrier reported it. `code` stays open here: - * the closed RPC code union belongs to the carrier package, which already - * depends on this one, so naming it would invert that edge. + * Merge-extensible Remote failure vocabulary: this package declares the + * universal carrier codes once; the Gateway merges its infrastructure codes + * and every owner merges its domain codes next to the throwing code. */ -export interface RemoteFailure { - readonly code: string - readonly message: string - readonly details: object +export interface RemoteErrorDetailsMap { + /** Owner-side business validation refused the request; `issues` carries codec output when one produced it. */ + 'gateway/bad-request': { readonly issues?: readonly object[] } + /** The call was cancelled by the carrier signal or the backend. */ + 'gateway/cancelled': {} + /** Carrier, dispatch, or unclassified Host failure. */ + 'gateway/internal': {} } +/** Every declared Remote failure code. */ +export type RemoteErrorCode = keyof RemoteErrorDetailsMap + +/** + * One Remote call's failure: the code-discriminated union of RemoteError + * instances, so a `code` branch narrows `details` with no cast. + */ +export type RemoteFailure = { + [Code in RemoteErrorCode]: import('./remote-error.ts').RemoteError +}[RemoteErrorCode] + /** * What every generated Remote method resolves to. The Remote face itself folds * carrier failures into the error branch, so no consumer wraps a call to diff --git a/packages/util/time/package.json b/packages/util/time/package.json new file mode 100644 index 0000000000..99b058d735 --- /dev/null +++ b/packages/util/time/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-util-time", + "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", + "version": "0.1.2-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/time" + }, + "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:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts new file mode 100644 index 0000000000..efa91dfb30 --- /dev/null +++ b/packages/util/time/src/index.ts @@ -0,0 +1,33 @@ +/** + * Time vocabulary shared by the wire boundaries that accept a caller's zone. + * Validation and canonicalization only: this library formats nothing and owns + * no failure vocabulary — each boundary declares and throws its own refusal. + * @module @deepseek-ai/dsh-time + */ + +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +/** + * Validate and canonicalize one caller-supplied IANA zone at a wire boundary. + * + * The canonical name is what a later reader needs: a zone identity is stored on + * durable records and resolved again by another process, so an alias accepted + * here would not compare equal to the zone a reader derives. + * @param value - the caller's reported zone name. + * @returns the canonical zone, or `undefined` when the name is unusable. + */ +export function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) + .resolvedOptions().timeZone + /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined + return canonical + } catch { + // Intl rejects unsupported zone names; the caller maps that parser rejection. + return undefined + } +} diff --git a/packages/util/time/src/invariant.ts b/packages/util/time/src/invariant.ts new file mode 100644 index 0000000000..2a52f0ffb3 --- /dev/null +++ b/packages/util/time/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-time`. + * @module @deepseek-ai/dsh-time/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-util-time' + +/** Cordis companion plugin name. */ +export const name = 'time-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its + * zone-canonicalization algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/time/tests/time.spec.ts b/packages/util/time/tests/time.spec.ts new file mode 100644 index 0000000000..905c555700 --- /dev/null +++ b/packages/util/time/tests/time.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' + +describe('canonicalClientTimeZone', () => { + it('accepts UTC and Area/Location names unchanged', () => { + expect(canonicalClientTimeZone('UTC')).toBe('UTC') + expect(canonicalClientTimeZone('Asia/Shanghai')).toBe('Asia/Shanghai') + expect(canonicalClientTimeZone('Europe/London')).toBe('Europe/London') + }) + + it('answers the platform-canonical name rather than the alias asked for', () => { + // A durable record is compared against the zone a later reader derives, so + // an alias must not survive the boundary. Which name each alias group + // resolves to is the runtime's ICU data, not this library's choice. + const canonical = canonicalClientTimeZone('Asia/Chongqing') + expect(canonical).not.toBe('Asia/Chongqing') + expect(canonicalClientTimeZone(canonical ?? '')).toBe(canonical) + }) + + it('refuses blank, padded, abbreviated, and single-segment names', () => { + for (const value of ['', ' ', ' UTC', 'UTC ', 'CST', 'GMT+8', 'Asia', 'utc']) { + expect(canonicalClientTimeZone(value)).toBeUndefined() + } + }) + + it('refuses a well-formed name the platform does not support', () => { + expect(canonicalClientTimeZone('Not/A_Real_Zone')).toBeUndefined() + }) +}) diff --git a/packages/util/time/tsconfig.json b/packages/util/time/tsconfig.json new file mode 100644 index 0000000000..779effc3cc --- /dev/null +++ b/packages/util/time/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index c3126d9aa7..21cd0a96e8 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index fcdbf049bf..4eee465961 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -7,6 +7,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-typert-protocol' /** * Identifies one workspace record. A generated uuid, never the path: path @@ -14,6 +15,13 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' */ export type WorkspaceId = Branded<'WorkspaceId'> +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No registration carries that Workspace identity. */ + 'workspace/not-found': { readonly workspaceId: WorkspaceId } + } +} + /** * One workspace: a stable id over an existing directory, a display title, and * an ordered candidate account of sessions. Membership requires both an id in diff --git a/packages/workspace/workspace/tsconfig.json b/packages/workspace/workspace/tsconfig.json index eb18440d55..d007df1796 100644 --- a/packages/workspace/workspace/tsconfig.json +++ b/packages/workspace/workspace/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../session/session-persistence" }, + { + "path": "../../typert/protocol" + }, { "path": "../../util/brand" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e582ee5fd..d607e39d27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -837,6 +837,9 @@ importers: '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto + '@deepseek-ai/dsh-util-time': + specifier: workspace:^ + version: link:../../util/time '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path @@ -2911,6 +2914,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@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 @@ -3063,9 +3069,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -3103,9 +3106,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3239,9 +3239,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -5151,9 +5148,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules @@ -8502,6 +8496,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -8559,6 +8556,9 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval + '@deepseek-ai/dsh-util-time': + specifier: workspace:^ + version: link:../../util/time packages/subagent/subagent-acp: dependencies: @@ -9403,6 +9403,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -9716,6 +9719,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/time: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/util/timeout: devDependencies: '@deepseek-ai/cordis': @@ -10173,6 +10185,9 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol python/sdk-runtime: dependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index 759b985d1b..e18c1b6a26 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -235,6 +235,10 @@ "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], + // util/ folders are role-named without the util- prefix their npm names carry, + // so this pair stays hand-written like dsh-util-crypto above. + "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], + "@deepseek-ai/dsh-util-time/invariant": ["./packages/util/time/src/invariant.ts"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 561cee9b17..1849fce913 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -130,6 +130,7 @@ { "path": "./packages/util/launch-environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/home-paths" }, + { "path": "./packages/util/time" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/crypto" }, { "path": "./packages/util/workspace-path" }, From d40d678b93489c4d906f25cb2049bc3b9ffa7b0d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:44:23 +0800 Subject: [PATCH 052/429] fix(api): repair runtime closure, gateway client bundle, and $host coverage - python/sdk-runtime: add @deepseek-ai/dsh-util-time so the runtime dependency closure stays closed (dsh-subagent now depends on it). - api/gateway: drop the dsh.client.external request for dsh-typert-protocol and admit the protocol package into INLINE_SAFE instead. The loader module table has no supplier for the protocol package, so the built client factory threw at require time; the protocol layer is duplication-safe by design (string marker, code-based discrimination), which is the inline-safe admission criterion. - api/gateway tests: cover the $host getter (live service read, pre-ready home, and the construction-time fallback after the Connection service is withdrawn). --- packages/api/gateway/package.json | 3 -- .../api/gateway/tests/gateway.client.spec.ts | 30 +++++++++++++++++++ packages/client/tsdown.client.ts | 2 +- pnpm-lock.yaml | 3 ++ python/sdk-runtime/package.json | 1 + 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index e961defbad..d5601ab188 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -35,9 +35,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-typert-protocol" - ], "inject": [ "@deepseek-ai/dsh-typert-registry", "@deepseek-ai/dsh-client-connection" diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 519b4dc0cc..829afc6c2f 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' import { apply as applyConnection, + type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' @@ -548,6 +549,35 @@ describe('Client Remote transport readiness', () => { await client.dispose() }) + it('reports Host facts as plain reads and keeps them through Connection withdrawal', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const generation = new GenerationHarness() + const live: { snapshot: ConnectionGeneration | undefined } = { snapshot: undefined } + const handle = { + isLoopback: true, + generation: { getSnapshot: () => live.snapshot, subscribe: () => () => {} }, + rpc: { + call: vi.fn(), + open: () => unexpectedInProcessStream(), + }, + registerGenerationSource: generation.register, + start: () => ({ stop: () => {} }), + } as unknown as ConnectionHandle + const withdraw = ctx.provide('connection', handle) + const client = ctx.plugin({ inject, apply }) + await client + const remote = ctx.remote + + expect(remote.$host).toEqual({ home: undefined, isLoopback: true }) + live.snapshot = { id: 1, host: { home: '/hosts/primary' } } + expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + + withdraw() + expect(ctx.get('connection')).toBeUndefined() + expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + }) + it('starts after Loader settlement and stops the owned loop on disposal', async () => { const readiness = deferredReadiness() const { client, start, stop } = await loaderReadinessBench(readiness.promise) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 984a6a6828..f31d1510e3 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|typert-protocol|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d607e39d27..9b37eee6d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10539,6 +10539,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../packages/interaction/user-questions + '@deepseek-ai/dsh-util-time': + specifier: workspace:^ + version: link:../../packages/util/time '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../../packages/web/web diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 331d388a0f..61dc113188 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -119,6 +119,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", From 5af9eec51c3d0612f2583670dd13407534bc5a9f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:56:24 +0800 Subject: [PATCH 053/429] fix(api): address review findings on the gateway client failure face - classify a carrier throw under a caller-aborted signal as gateway/cancelled instead of gateway/internal, matching the code the Host produces when the abort wins the wire round-trip. - read $host facts from the construction-time Connection handle, matching $stream; the service cannot be replaced without restarting this plugin, so the live re-lookup was dead complexity. - state rebuiltFailure's actual contract in its comment: codes pass through verbatim without runtime validation. - correct the @module name in dsh-util-time. --- packages/api/gateway/src/client/index.ts | 22 ++++++++---- .../api/gateway/tests/gateway.client.spec.ts | 36 +++++++++++++++++++ packages/util/time/src/index.ts | 2 +- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index e52bcd0f31..303f311d6a 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -183,10 +183,9 @@ class ClientRemoteService extends Service implements ClientRemote { } get $host(): RemoteHostFacts { - const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined ?? this.connection return { - home: connection.generation.getSnapshot()?.host.home, - isLoopback: connection.isLoopback, + home: this.connection.generation.getSnapshot()?.host.home, + isLoopback: this.connection.isLoopback, } } @@ -438,7 +437,10 @@ class ClientRemoteService extends Service implements ClientRemote { return { ok: true, value: result.value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly - // faults, so they join the same error branch. + // faults, so they join the same error branch. A caller-aborted call is a + // cancellation even when the local throw wins the race against the wire + // round-trip, so it gets the same code the Host would have produced. + if (prepared.signal.aborted) return cancelledFailure(endpoint, error) return carrierFailure(endpoint, error) } } @@ -721,6 +723,13 @@ function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { + return { + ok: false, + error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }), + } +} + function internalFailure(message: string): Extract, { readonly ok: false }> { return { ok: false, error: new RemoteError('gateway/internal', message, {}) } } @@ -738,8 +747,9 @@ export function isRemoteFailure(error: unknown): error is RemoteFailure { /** * Rebuild the wire failure as a local RemoteError instance so the error branch - * carries a real Error and `throw result.error` keeps throw semantics. The wire - * is a validation boundary: codes outside the merged map surface as-is. + * carries a real Error and `throw result.error` keeps throw semantics. The code + * is passed through verbatim without runtime validation: a code outside this + * Client's merged map still surfaces as-is, so a newer Host stays readable. */ function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure { return new RemoteError(error.code as never, error.message, error.details as never) diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 829afc6c2f..b6a1d12414 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1305,6 +1305,42 @@ describe('Client Typert API', () => { }) }) + it('classifies a carrier throw under a caller-aborted signal as gateway/cancelled', async () => { + const controller = new AbortController() + const ctx = await bench(vi.fn().mockImplementation(async () => { + controller.abort() + throw new Error('carrier aborted mid-flight') + })) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' }, controller.signal)) + .resolves.toMatchObject({ + ok: false, + error: { + code: 'gateway/cancelled', + message: 'client api: Remote invocation "probe/create" was aborted', + details: {}, + }, + }) + }) + + it('keeps a carrier throw under an unaborted caller signal in the internal branch', async () => { + const controller = new AbortController() + const ctx = await bench(vi.fn() + .mockRejectedValue(new Error('carrier offline'))) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' }, controller.signal)) + .resolves.toMatchObject({ + ok: false, + error: { + code: 'gateway/internal', + message: 'client api: probe/create failed: carrier offline', + details: {}, + }, + }) + }) + it('owns each $on subscription in the calling fiber', async () => { const { ctx, client, carrier } = await eventBench() const seen: string[] = [] diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index efa91dfb30..1867965cf2 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -2,7 +2,7 @@ * Time vocabulary shared by the wire boundaries that accept a caller's zone. * Validation and canonicalization only: this library formats nothing and owns * no failure vocabulary — each boundary declares and throws its own refusal. - * @module @deepseek-ai/dsh-time + * @module @deepseek-ai/dsh-util-time */ /** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ From 2f2e6d627b28a1c36001fea2bc0a2124c4f3c5f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:09:55 +0800 Subject: [PATCH 054/429] fix(api): resolve review findings on stream boundary, inject staleness, and ctx discipline - api/gateway: mark terminal Remote-stream escapes (carrier retry exhaustion and pre-acceptance end classification) as RemoteError('gateway/internal') at the two escape points; marked failures pass through verbatim. The carrier class stays the retry-internal signal for carrierFailed and the ended(true) retry trigger. Regression coverage lands on the session and workspace stream consumers. - client/ui-tool, client/ui-workspace: read $host.home through a hooks observable subscribed to connection/reset; the slot renderer memoizes inject results per entry, so the previous plain-value injection froze home at the first render. - client/ui-settings-models: components no longer receive ctx; apply binds the credential and settings Remote operations into callbacks, and the settings/conflict code judgment stays in the apply world. --- .../api/gateway/src/client/remote-stream.ts | 21 +++- .../tests/control-retry.client.spec.ts | 16 ++- .../tests/session.client.spec.ts | 20 +++- .../tests/transport.client.spec.ts | 21 ++++ .../src/client/CustomProviderCard.tsx | 22 ++-- .../src/client/DeepSeekOnboardingDialog.tsx | 10 +- .../src/client/ModelListEditor.tsx | 16 +-- .../src/client/ModelsSection.tsx | 38 +++--- .../src/client/ProviderEditor.tsx | 41 ++++--- .../ui-settings-models/src/client/index.ts | 9 +- .../src/client/operations.ts | 109 ++++++++++++++++++ .../tests/apply.client.spec.ts | 4 +- .../tests/components.client.spec.tsx | 42 ++++--- .../tests/onboarding-dialog.client.spec.tsx | 4 +- .../tests/provider-form.client.spec.tsx | 30 +++-- packages/client/ui-tool/src/client/apply.ts | 10 +- .../ui-tool/src/client/contract/slots.ts | 15 ++- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 5 +- .../tests/tool-call-tree.client.spec.tsx | 2 +- .../tests/tool-details-render.client.tsx | 2 +- .../ui-workspace/src/client/contract/slots.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 10 +- .../src/client/rows/WorkspaceBrowser.tsx | 3 +- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../tests/host-home-staleness.client.spec.tsx | 91 +++++++++++++++ .../tests/workspace-browser.client.spec.tsx | 4 +- 27 files changed, 451 insertions(+), 111 deletions(-) create mode 100644 packages/client/ui-settings-models/src/client/operations.ts create mode 100644 packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 71f1f546ae..a2b018ae91 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -1,5 +1,6 @@ /** Reconnecting lifecycle for one single-consumer Remote stream. */ +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError } from './stream-client.ts' @@ -128,7 +129,7 @@ export class RemoteStream implements AsyncIterable> } catch (error) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - if (!(error instanceof RemoteStreamCarrierError)) throw error + if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error) this.options.carrierFailed?.(error) if (revision !== this.revision) continue attempt++ @@ -137,7 +138,7 @@ export class RemoteStream implements AsyncIterable> } catch (retryError) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - throw retryError + throw terminalStreamFailure(retryError) } } finally { this.generationAbort = undefined @@ -195,6 +196,22 @@ async function waitForRemoteStreamRetry( }) } +/** + * Mark a terminal escape before it crosses the stream boundary: consumers + * discriminate failures by code, so an unmarked throw reads as a local bug. + * Marked failures pass through verbatim. The carrier class never escapes as a + * terminal outcome — it stays the retry-internal signal fed to `carrierFailed` + * and the `ended(true)` retry trigger. + */ +function terminalStreamFailure(error: unknown): Error { + return remoteErrorOf(error) ?? new RemoteError( + 'gateway/internal', + error instanceof Error ? error.message : String(error), + {}, + { cause: error }, + ) +} + function isAborted(signal: AbortSignal): boolean { return signal.aborted } diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 234251babb..7e127fbff0 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError, @@ -106,11 +107,24 @@ describe('RemoteStream', () => { { terminal: repeated }, ], carrierFailed) - await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated) + await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + isDSHRemoteGatewayError: true, + code: 'gateway/internal', + message: 'isolated retry failed', + details: {}, + cause: repeated, + }) expect(carrierFailed).toHaveBeenNthCalledWith(1, first) expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) }) + it('passes a marked Remote failure through the terminal boundary verbatim', async () => { + const failure = new RemoteError('gateway/internal', 'host stream failed', {}) + const stream = supervisor(hostSource(true).connection, [{ terminal: failure }]) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(failure) + }) + it('waits for a replacement Host generation after observing unavailability', async () => { let available = false let listener: (() => void) | undefined diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index f59ed3c287..565dcf99bf 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session, type SessionOptions } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' @@ -85,10 +86,25 @@ describe('Session open', () => { expect(snapshot.openError?.code).toBe('session/not-found') }) - it('propagates a non-Remote throw raised while opening', async () => { + it('lands exhausted carrier retries in openState=error as gateway/internal', async () => { + const { api, session } = makeSession() + // Two consecutive carrier losses before any opening is accepted exhaust the + // Gateway's retry budget; the escaping failure crosses the stream boundary marked. + api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down')) + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ + code: 'gateway/internal', message: 'history carrier down', + }) + expect(api.followStarts).toHaveLength(2) + }) + + it('lands a Gateway-marked stream failure in openState=error', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) - await expect(session.open()).rejects.toThrow('socket died') + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' }) }) it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 52d7a21020..fdfbf23105 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -231,6 +231,27 @@ describe('Workspace Controller Client apply', () => { expect(ctx.get('workspaces')).toBeUndefined() }) + it('publishes exhausted carrier retries as a gateway/internal error state', async () => { + const ctx = new Context() + // Neither generation reaches an accepted baseline, so the retry budget runs + // out and the escaping carrier failure crosses the stream boundary marked. + const remote = new ScriptedWorkspaceRemote([ + { frames: [], error: new RemoteStreamCarrierError('generation lost') }, + { frames: [], error: new RemoteStreamCarrierError('generation lost again') }, + ]) + provideClientServices(ctx, remote) + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + state: 'error', + error: { code: 'gateway/internal', message: 'generation lost again' }, + }) + }) + expect(remote.calls).toBe(2) + await fiber.dispose() + }) + it('marks carrier loss while retrying and publishes a later protocol failure', async () => { const ctx = new Context() const remote = new ScriptedWorkspaceRemote([ diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index de1c20b8bf..b5034b12b4 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -23,7 +23,6 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' @@ -31,6 +30,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' import { deriveKeyRef } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -59,8 +59,8 @@ export interface CustomProviderCardProps { * than a silent overwrite of its whole profile. */ revision: number - /** The page plugin's context, whose Remote namespaces carry the write and the endpoint interrogation. */ - ctx: ClientContext + /** The Host operations this card writes and interrogates through. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -75,7 +75,7 @@ export interface CustomProviderCardProps { * @returns the creation card. */ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { - const { taken, protocols, ctx, t } = props + const { taken, protocols, operations, t } = props // The write is checked against the revision on which this draft was opened. const [openedAt] = useState(() => props.revision) const [route, setRoute] = useState('') @@ -147,15 +147,13 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // `taken` is a snapshot too, so the id check alone cannot see a route // declared after this card opened; the revision makes that race a // `settings-conflict` instead of a write over the other profile. - const response = await ctx.remote.settings.mutate( + const written = await operations.writeSettings( NS, [{ op: 'set', path: ['providers', route], value: profile as JsonValue }], openedAt, ) - if (!response.ok) { - return response.error.code === 'settings/conflict' - ? t('conflict') - : response.error.message + if (written.kind !== 'written') { + return written.kind === 'conflict' ? t('conflict') : written.message } // The provider now exists. A retry after the key write below fails must // not re-run this mutate: the revision it holds is the one this write @@ -164,10 +162,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { setCommitted(true) } if (storesKey) { - const stored = await ctx.remote.credentials.set(keyRef, keyValue) + const stored = await operations.storeCredential(keyRef, keyValue) // The profile landed; saying the key did not is the only honest report, // and the retry above now goes straight back to this write. - if (!stored.ok) return stored.error.message + if (stored !== undefined) return stored } return undefined } @@ -274,7 +272,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} - ctx={ctx} + operations={operations} t={t} disabled={profileDisabled} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 546f89e4a0..3d764a1ed6 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -8,11 +8,11 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' 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' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -27,8 +27,8 @@ export interface DeepSeekOnboardingInjected { } /** Shared Models-page join controller. */ controller: ModelsSettingsStore - /** The plugin context the reused Models credential editor writes through. */ - ctx: ClientContext + /** The Host operations the reused Models credential editor writes through. */ + operations: ModelsOperations /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ @@ -51,7 +51,7 @@ function assertNever(_value: never): never { * @returns the onboarding modal or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { - const { complete, controller, useModels, ctx, schema, t } = props + const { complete, controller, useModels, operations, schema, t } = props const state = useModels(snapshot => snapshot) const readiness = onboardingReadiness(state) @@ -106,7 +106,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): namespace={namespace} schema={schema} settingsPath={row.entry.settingsPath} - ctx={ctx} + operations={operations} t={t} readOnly={false} hideTitle diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 69714486c2..a81dde113d 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,10 +16,10 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' +import type { ModelsOperations } from './operations.ts' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -79,8 +79,8 @@ export interface ModelListEditorProps { * told what the field already says. */ probeBlocked?: keyof typeof en | undefined - /** The page plugin's context, whose `remote.llm` namespace answers the fetch action. */ - ctx: ClientContext + /** The Host operations whose interrogation answers the fetch action. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ @@ -157,7 +157,7 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft { * @returns the model-list editor. */ export function ModelListEditor(props: ModelListEditorProps): ReactNode { - const { models, onChange, probe, ctx, t, disabled } = props + const { models, onChange, probe, operations, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) const [candidates, setCandidates] = useState(undefined) @@ -229,17 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await ctx.remote.llm.discoverModels(probe.settingsNs, { + const answer = await operations.discoverModels(probe.settingsNs, { ...probe.provider === undefined ? {} : { provider: probe.provider }, ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL }, ...probe.api === undefined ? {} : { api: probe.api }, ...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey }, }) - if (!response.ok) { - setFailure(response.error.message) + if (answer.kind === 'refused') { + setFailure(answer.message) return } - const found = response.value + const found = answer.models if (found.length === 0) { setFailure(t('fetchEmpty')) return diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 4611067f3e..b99767a364 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -14,7 +14,6 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls this package's SlotMap merge (the two Models child slots). @@ -22,6 +21,7 @@ import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsStore, ProviderRow } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -35,8 +35,8 @@ export interface ModelsSectionInjected { /** Page snapshot bound by the UI renderer as useSnapshot. */ snapshot: ModelsSettingsStore['store'] } - /** The page plugin's context, whose Remote namespaces the editors write through. */ - ctx: ClientContext + /** The Host operations the section and its cards invoke. */ + operations: ModelsOperations /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Section copy. */ @@ -81,7 +81,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'ctx' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -105,26 +105,26 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * and the whole operation safely retryable; both unsets are idempotent. * The settings removal names the profile rather than rebuilding its whole * namespace from a partial view. - * @param ctx - the page plugin's context, carrying the settings and credential Remote namespaces. + * @param operations - the page's Host operations. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - ctx: ClientContext, + operations: ModelsOperations, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { if (target.credentialRef !== undefined) { - const credential = await ctx.remote.credentials.unset(target.credentialRef) - if (!credential.ok) return credential.error.message + const credential = await operations.removeCredential(target.credentialRef) + if (credential !== undefined) return credential } - const response = await ctx.remote.settings.mutate( + const written = await operations.writeSettings( target.settingsNs, [{ op: 'unset', path: [...target.settingsPath] }], undefined, ) - if (!response.ok) return response.error.message + if (written.kind !== 'written') return written.message await controller.load() return undefined } @@ -193,16 +193,16 @@ export function providerCopy(template: string, target: ProviderIdentity): string * @returns the section, or null while the shell has not injected yet. */ export function ModelsSection(props: ModelsSectionProps): ReactNode { - const { controller, useSnapshot, ctx, schema, t, renderSlot } = props + const { controller, useSnapshot, operations, schema, t, renderSlot } = props if ( - controller === undefined || useSnapshot === undefined || ctx === undefined + controller === undefined || useSnapshot === undefined || operations === undefined || schema === undefined || t === undefined ) return null - return + return } function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { - const { controller, ctx, schema, t } = injected + const { controller, operations, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) @@ -250,7 +250,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (deleteTarget === undefined || deleting) return setDeleting(true) setDeleteFailure(undefined) - void removeProviderProfile(ctx, controller, deleteTarget) + void removeProviderProfile(operations, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { setDeleteFailure(failure) @@ -331,7 +331,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - ctx, + operations, t, readOnly: !state.writable, onClose: (changed) => { closeSetup(changed, target) }, @@ -426,7 +426,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - ctx, + operations, t, readOnly: !state.writable, onClose: (changed) => { closeEditor(changed, target) }, @@ -466,7 +466,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS namespace={addNamespace} schema={schema} settingsPath={addTarget.settingsPath} - ctx={ctx} + operations={operations} t={t} readOnly={!state.writable} onClose={(changed) => { closeEditor(changed, addTarget) }} @@ -488,7 +488,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS protocols={protocols} /* v8 ignore next -- the card only opens from a button disabled without this namespace */ revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0} - ctx={ctx} + operations={operations} t={t} readOnly={!state.writable} onClose={(changed) => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 3c4cea5aab..b4664ce40f 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -23,7 +23,6 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -34,6 +33,7 @@ import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import { deriveKeyRef, protocolChoices } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -66,8 +66,8 @@ export interface ProviderEditorProps { schema: SettingsSchemaOperations /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] - /** The page plugin's context, whose Remote namespaces carry the writes and the endpoint interrogation. */ - ctx: ClientContext + /** The Host operations this card writes and interrogates through. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -155,7 +155,7 @@ function refFor( * @returns the editor card. */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { - const { namespace, schema, settingsPath, ctx, t } = props + const { namespace, schema, settingsPath, operations, t } = props const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) @@ -188,12 +188,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setKeyState(undefined) // The key state is a placeholder hint, not a precondition for editing: a // refused describe leaves the card without the "already configured" hint. - void ctx.remote.credentials.describe([keyRef]).then((response) => { - if (stale || !response.ok) return - setKeyState(response.value[keyRef]) + void operations.describeCredential(keyRef).then((described) => { + if (stale) return + setKeyState(described) }) return () => { stale = true } - }, [ctx, keyRef]) + }, [operations, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -277,19 +277,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? [{ op: 'set', path: [...settingsPath], value: {} }] : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) - if (!response.ok) { - return response.error.code === 'settings/conflict' - ? t('conflict') - : response.error.message - } - setCommittedOriginal(schema.getPath(response.value.user, settingsPath)) - setExpectedRevision(response.value.revision) + const written = await operations.writeSettings(ns, ops, expectedRevision) + if (written.kind !== 'written') return written.kind === 'conflict' ? t('conflict') : written.message + setCommittedOriginal(schema.getPath(written.view.user, settingsPath)) + setExpectedRevision(written.view.revision) setDraft(next) } if (keyValue.length > 0) { - const stored = await ctx.remote.credentials.set(keyRef, keyValue) - if (!stored.ok) return stored.error.message + const stored = await operations.storeCredential(keyRef, keyValue) + if (stored !== undefined) return stored } setKeyDraft('') return undefined @@ -464,7 +460,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : } + : ( + + )}
} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 8cb361ead6..721f807092 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -23,6 +23,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' +import { createModelsOperations } from './operations.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -43,6 +44,7 @@ const NS = 'settings.models' export type { ModelsSettingsState, ProviderDirectoryEntry, ProviderRow, } from './store.ts' +export type { ModelDiscoveryOutcome, ModelsOperations, SettingsWriteOutcome } from './operations.ts' /** * Refetch the page snapshot only after its first load: an unopened Models @@ -74,6 +76,9 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const schema = createSettingsSchemaOperations(ctx.settingsSchema) + // Bound once here, where the Remote namespaces are declared in this plugin's + // own `inject`; the cards receive callbacks and never a context. + const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, schema, ctx.settingsScope.describe()) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. @@ -81,14 +86,14 @@ export function apply(ctx: ClientContext): void { const injected = (): ModelsSectionInjected => ({ controller, hooks: { snapshot: controller.store }, - ctx, + operations, schema, t, }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ controller, hooks: { models: controller.store }, - ctx, + operations, schema, t, }) diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts new file mode 100644 index 0000000000..8ca6beb798 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -0,0 +1,109 @@ +/** + * The Host reads and writes the Models cards perform, as callbacks built in the + * plugin body. Cards receive these instead of a context: the outcomes name what + * a card renders — a stored view, a stale revision, a refusal message — so the + * failure codes and Remote namespaces stay in the apply world. + */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { + CredentialInfo, LlmDiscoveredModel, LlmModelDiscoveryRequest, + SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' + +/** What one namespace write answered. */ +export type SettingsWriteOutcome = + /** Committed; the view carries the stored user subtree and the new revision. */ + | { readonly kind: 'written'; readonly view: SettingsNamespaceView } + /** + * The stored revision moved after the card read it, so the draft is stale. + * The message stays for callers that report the Host diagnostic as it is. + */ + | { readonly kind: 'conflict'; readonly message: string } + /** Any other refusal, with the Host's own diagnostic. */ + | { readonly kind: 'refused'; readonly message: string } + +/** What one endpoint interrogation answered. */ +export type ModelDiscoveryOutcome = + /** The candidates the provider disclosed, in its own order. */ + | { readonly kind: 'found'; readonly models: readonly LlmDiscoveredModel[] } + /** The interrogation was refused, with the Host's own diagnostic. */ + | { readonly kind: 'refused'; readonly message: string } + +/** The Host operations the Models page and its cards invoke. */ +export interface ModelsOperations { + /** + * Read one credential reference's state. + * @param ref - credential reference name. + * @returns the state, or undefined when the reference is unknown or the read was refused. + */ + describeCredential(ref: string): Promise + /** + * Store one credential literal under its reference. + * @param ref - credential reference name. + * @param value - the literal to store. + * @returns the refusal message, or undefined once stored. + */ + storeCredential(ref: string, value: string): Promise + /** + * Remove one credential reference (idempotent). + * @param ref - credential reference name. + * @returns the refusal message, or undefined once removed. + */ + removeCredential(ref: string): Promise + /** + * Apply path operations to one settings namespace. + * @param ns - settings namespace identity. + * @param ops - ordered path operations against the stored section, as the + * wire takes them (the Remote signature owns the array). + * @param expectedRevision - revision the draft was opened at, or undefined to write unfenced. + * @returns the write outcome the card renders from. + */ + writeSettings( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise + /** + * Ask a provider endpoint what models it serves. + * @param settingsNs - namespace whose adapter family answers. + * @param request - endpoint facts as the form currently shows them. + * @returns the candidates, or the refusal. + */ + discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise +} + +/** + * Bind the page's Host operations to the plugin's own Remote namespaces. + * @param ctx - the page plugin's context, which declares `remote.credentials`, + * `remote.llm`, and `remote.settings` in its own `inject`. + * @returns the callbacks the section and its cards are injected with. + */ +export function createModelsOperations(ctx: ClientContext): ModelsOperations { + return { + describeCredential: async (ref) => { + const response = await ctx.remote.credentials.describe([ref]) + return response.ok ? response.value[ref] : undefined + }, + storeCredential: async (ref, value) => { + const response = await ctx.remote.credentials.set(ref, value) + return response.ok ? undefined : response.error.message + }, + removeCredential: async (ref) => { + const response = await ctx.remote.credentials.unset(ref) + return response.ok ? undefined : response.error.message + }, + writeSettings: async (ns, ops, expectedRevision) => { + const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) + if (response.ok) return { kind: 'written', view: response.value } + const { code, message } = response.error + return code === 'settings/conflict' ? { kind: 'conflict', message } : { kind: 'refused', message } + }, + discoverModels: async (settingsNs, request) => { + const response = await ctx.remote.llm.discoverModels(settingsNs, request) + return response.ok + ? { kind: 'found', models: response.value } + : { kind: 'refused', message: response.error.message } + }, + } +} 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 1d42d47682..aca0e3bfb7 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -85,7 +85,7 @@ describe('ui-settings-models apply', () => { expect(injected.t('deleteTitle')).toBe('删除 {provider}?') expect(typeof injected.controller.load).toBe('function') expect(injected.hooks.snapshot).toBe(injected.controller.store) - expect(injected.ctx).toBeDefined() + expect(typeof injected.operations.writeSettings).toBe('function') const onboarding = before.slots.entries('settings.onboarding') expect(onboarding).toHaveLength(2) expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({ @@ -99,7 +99,7 @@ describe('ui-settings-models apply', () => { deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected )() expect(deepSeekInjected.hooks.models).toBe(injected.controller.store) - expect(deepSeekInjected.ctx).toBeDefined() + expect(typeof deepSeekInjected.operations.storeCredential).toBe('function') const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() 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 361e080e32..64b920a81a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -18,6 +18,8 @@ import { import { apiKeyFailure } from '../src/client/apiKey.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' +import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -220,6 +222,20 @@ function ctxWith(face: object): PageContext { return ctx } +/** + * The cards' injected Host operations over the same script, bound once per face + * as the plugin body binds them: an editor effect keyed by this face would + * otherwise re-probe on every render. + */ +const operations = new WeakMap() +function operationsWith(face: object): ModelsOperations { + const existing = operations.get(face) + if (existing !== undefined) return existing + const bound = createModelsOperations(ctxWith(face)) + operations.set(face, bound) + return bound +} + /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -252,7 +268,7 @@ async function mountFace(scripted: ReturnType) { const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - ctx, + operations: operationsWith(face), schema: settingsSchema, t, renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], @@ -387,7 +403,7 @@ describe('ModelsSection', () => { render( null} @@ -412,7 +428,7 @@ describe('ModelsSection', () => { render( null} @@ -494,7 +510,7 @@ describe('ModelsSection', () => { namespace={wireNamespaces()[0]!} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} credentialOnly @@ -747,7 +763,7 @@ describe('ModelsSection', () => { namespace={overridden} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -977,7 +993,7 @@ describe('ModelsSection', () => { namespace={bare} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -1138,7 +1154,7 @@ describe('ModelsSection', () => { render( null} @@ -1272,7 +1288,7 @@ describe('ModelsSection', () => { render( null} @@ -1295,7 +1311,7 @@ describe('ModelsSection', () => { render( null} @@ -1357,7 +1373,7 @@ describe('ModelsSection', () => { render( null} @@ -1370,7 +1386,7 @@ describe('ModelsSection', () => { // would widen the write for no benefit. const { face, mutate, controller } = await mountSection() await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] }, ) @@ -1387,7 +1403,7 @@ describe('ModelsSection', () => { }) const before = controller.store.getSnapshot().rows const failure = await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, ) @@ -1438,7 +1454,7 @@ describe('ModelsSection', () => { unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))), }) const failure = await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-pi-ai', 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 92d57eb776..59a9bc4cd6 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 @@ -9,6 +9,7 @@ import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ModelsSettingsStore } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -130,6 +131,7 @@ function harness(options: { } // The page plugin's context, scripted down to the namespaces it reaches. const ctx = { remote: face } as never + const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() const complete = vi.fn() @@ -143,7 +145,7 @@ function harness(options: { useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), - ctx, + operations, schema: settingsSchema, t: key => en[key], } diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 96e39a8bfc..1abe0f6664 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -11,6 +11,8 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' +import type { ModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -155,6 +157,20 @@ function ctxWith(face: object): PageContext { return ctx } +/** + * The cards' injected Host operations over the same script, bound once per face + * as the plugin body binds them: an editor effect keyed by this face would + * otherwise re-probe on every render. + */ +const operations = new WeakMap() +function operationsWith(face: object): ModelsOperations { + const existing = operations.get(face) + if (existing !== undefined) return existing + const bound = createModelsOperations(ctxWith(face)) + operations.set(face, bound) + return bound +} + /** The settings write one card produced, as the scripted face recorded it. */ interface MutateCall { ns: string @@ -189,7 +205,7 @@ async function mountSection(options: Parameters[0] = {}) { const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - ctx: ctxWith(scripted.face), + operations: operationsWith(scripted.face), schema: settingsSchema, t, renderSlot: () => null, @@ -576,7 +592,7 @@ describe('endpoint interrogation', () => { const scripted = scriptedFace() render( , ) @@ -701,7 +717,7 @@ describe('provider rows', () => { render( null} @@ -725,7 +741,7 @@ describe('hand-declared providers', () => { taken={['openai']} protocols={PROTOCOLS} revision={7} - ctx={ctxWith(scripted.face)} + operations={operationsWith(scripted.face)} t={t} readOnly={false} onClose={onClose} @@ -1152,7 +1168,7 @@ describe('hand-declared providers', () => { it('surfaces a refused write without closing', async () => { const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings/rejected'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: refused }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: refused }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1166,7 +1182,7 @@ describe('hand-declared providers', () => { it('translates a create refused by a newer namespace revision', async () => { const conflicting = vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: conflicting }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: conflicting }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1180,7 +1196,7 @@ describe('hand-declared providers', () => { it('reports a stored profile whose key write was refused', async () => { const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ set }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ set }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index b9ae865929..b0eab4996d 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -23,7 +24,14 @@ export const inject = ['slots', 'remote'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - const toolInject = () => ({ home: ctx.remote.$host.home }) + // Host facts are plain reads; a reset is what announces the generation that + // published them, so the views re-read on it instead of freezing the value + // the entry's first render saw (inject results are memoized per registration). + const hostHome: HostObservable = { + getSnapshot: () => ctx.remote.$host.home, + subscribe: listener => ctx.on('connection/reset', listener), + } + const toolInject = () => ({ hooks: { hostHome } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index bf10b4d3b7..fc38496e6d 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,7 @@ /** Tool UI slot declarations and their composed component props. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' 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' @@ -47,8 +49,15 @@ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ export type ToolHostHomeInjected = { - /** Host account home, absent until the Connection is ready. */ - home: string | undefined + hooks: { + /** + * Host account home, absent until the Connection is ready. A hook rather + * than a value: the renderer memoizes an entry's inject result for the + * registration's lifetime, so a home read there would freeze at whatever + * the first render saw. + */ + hostHome: HostObservable + } } /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 1114d29146..7e10afd82c 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, home, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostHome, t, }: ToolTreeProps) { + const home = useHostHome(value => value) const block = node.data.root return ( ) { + block, cwd, useHostHome, t, +}: Pick) { + const home = useHostHome(value => value) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) 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 5740154547..26743e3387 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 @@ -49,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - home, + useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'], t, } as unknown as ToolTreeProps } 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 e301fc4834..756d71877e 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -157,7 +157,7 @@ export function renderToolDetails( return selector(home)} t={t} /> } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index d33944a914..7bd9d670f4 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -87,9 +87,15 @@ export type DirectoryPickingHooks = PropsHooks + } /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index ac2e5c6e1a..2f55dd506d 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -89,6 +89,13 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.slots.subscribe(hole, listener), }) const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') + // Host facts are plain reads; a reset is what announces the generation that + // published them, so the rows re-read on it instead of freezing the value + // the entry's first render saw (inject results are memoized per registration). + const hostHome: HostObservable = { + getSnapshot: () => ctx.remote.$host.home, + subscribe: listener => ctx.on('connection/reset', listener), + } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session inherits @@ -122,8 +129,7 @@ export function apply(ctx: Context): void { await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => workspaces.create(input), - home: ctx.remote.$host.home, - hooks: { directoryFlow: browserFlowSource }, + hooks: { directoryFlow: browserFlowSource, hostHome }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index a312a9b878..09295168c5 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,10 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - home, + useHostHome, renderSlot, t, }: WorkspaceBrowserProps) { + const home = useHostHome(value => value) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 511c6c33f9..87536d4646 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -159,7 +159,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.home).toBeUndefined() + expect(browser.hooks.hostHome.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx new file mode 100644 index 0000000000..1b3e256c97 --- /dev/null +++ b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +/** + * Host home reaches the browsing region through the assembled renderer, which + * memoizes a root entry's inject result for the whole registration — so a home + * read once at first render would freeze there. This spec drives the real slot + * renderer (not a direct `entry.inject()` call, which bypasses that memo) and + * pins that a home learned after first render reaches the rendered rows. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' + +usePinnedBrowserLanguages('zh-CN') + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +/** Test-owned sidebar shell role: declares and renders the browsing region. */ +type FrameProps = PropsRenderSlots<'sidebar.workspaces'> +function SidebarFrame({ renderSlot }: FrameProps) { + return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} +} + +/** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */ +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.releaseWorkspaceSource() + const directoryPicker = {} + const remote = new TestRemote(runtime.ctx) + Object.assign(remote, { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'Project', path: '/home/u/Documents/project', + sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + return { runtime, remote } +} + +/** Open the Workspace row's hover card, which is where the home abbreviation shows. */ +function openHoverCard(): void { + const row = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) +} + +/** Close it again, so the next hover rebuilds the card from current props. */ +function closeHoverCard(): void { + const row = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerLeave(row) + act(() => { vi.advanceTimersByTime(500) }) +} + +describe('Host home in the assembled browsing region', () => { + it('abbreviates the path once a home learned after first render reaches the rows', async () => { + // First render precedes the ready frame: the shell mounts while the carrier + // is still handshaking, so the Host reports no home yet. + const { runtime, remote } = await bench() + remote.$host = { home: undefined, isLoopback: true } + runtime.renderRoot() + vi.useFakeTimers() + try { + openHoverCard() + expect(screen.getByText('/home/u/Documents/project')).toBeTruthy() + closeHoverCard() + + // The ready frame lands: `$host.home` now answers, and the generation is + // announced through the reset every consumer already listens to. + remote.$host = { home: '/home/u', isLoopback: true } + act(() => { runtime.ctx.emit('connection/reset') }) + openHoverCard() + + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index e9933f3ea3..afb973a6df 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -84,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - home: undefined, + useHostHome: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -109,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - home: '/home/u', + useHostHome: selector => selector('/home/u'), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) From 41cd24f3f65e61ee3c9938aaed89e6adad553fb7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:29:02 +0800 Subject: [PATCH 055/429] test(api): cover the non-Error terminal escape in RemoteStream The terminal fold's String(error) arm had no coverage; a generation that rejects with a bare string now pins the marked gateway/internal outcome. --- .../gateway/tests/control-retry.client.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 7e127fbff0..29736817e0 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -118,6 +118,24 @@ describe('RemoteStream', () => { expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) }) + it('folds a non-Error terminal escape into a marked gateway/internal failure', async () => { + const stream = new RemoteStream(hostSource(true).connection, { + name: 'fixture stream', + open: () => ({ + [Symbol.asyncIterator]: (): AsyncIterator => ({ + next: vi.fn<() => Promise>>().mockRejectedValue('generation exploded'), + }), + }), + ended: () => new Error('fixture stream ended'), + }) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + isDSHRemoteGatewayError: true, + code: 'gateway/internal', + message: 'generation exploded', + }) + }) + it('passes a marked Remote failure through the terminal boundary verbatim', async () => { const failure = new RemoteError('gateway/internal', 'host stream failed', {}) const stream = supervisor(hostSource(true).connection, [{ terminal: failure }]) From 2b750cfb5173982f73e719b8a1418dba2eb1e2ab Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:32:38 +0800 Subject: [PATCH 056/429] docs(api): document the converged ctx.remote programming surface - new cookbook page adding-a-remote-api (en/zh): the five-step HOW-TO for declaring, failing, registering, consuming, and testing a Remote endpoint. - new Agent Note ctx-remote-failure-vocabulary records this round's decisions and alternatives; the 2026-08-02 and 2026-08-10 notes are rewritten to the shipped facts (RemoteError vocabulary, $host, the retired ApiProxy statements). - package READMEs pick up the new failure-face contracts (typert/protocol, api/gateway, api/remotes, test-support/client-runtime), dsh-util-time gains its README and registry entries, and stale connection/WorkspaceError/legacy-code statements are corrected (ui-settings, ui-settings-models, workspace-controller, docs/subsystems/typert incl. the TypertGatewayErrorCode type-equiv block). - packages/AGENTS.md gains the Remote-failure rule bullet; its doc budget rises 675 -> 714: the bullet is the compressed remainder after relocating detail to the cookbook and the Agent Note. --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 48 +++-- ...026-08-02-typert-remote-method-calls.zh.md | 48 +++-- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 4 +- ...8-10-unary-apiproxy-remote-migration.zh.md | 4 +- ...28-ctx-remote-failure-vocabulary.i18n.yaml | 6 + ...026-08-28-ctx-remote-failure-vocabulary.md | 88 ++++++++ ...-08-28-ctx-remote-failure-vocabulary.zh.md | 88 ++++++++ docs/cookbook/adding-a-remote-api.i18n.yaml | 6 + docs/cookbook/adding-a-remote-api.md | 197 ++++++++++++++++++ docs/cookbook/adding-a-remote-api.zh.md | 197 ++++++++++++++++++ docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 36 ++-- docs/subsystems/typert.zh.md | 36 ++-- packages/AGENTS.md | 1 + packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 10 +- packages/api/gateway/README.zh.md | 10 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 2 +- .../api/workspace-controller/README.zh.md | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/README.zh.md | 4 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 18 +- .../test-support/client-runtime/README.zh.md | 18 +- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 22 +- packages/typert/protocol/README.zh.md | 22 +- packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 5 +- packages/util/README.zh.md | 5 +- packages/util/time/README.i18n.yaml | 6 + packages/util/time/README.md | 68 ++++++ packages/util/time/README.zh.md | 68 ++++++ scripts/doc-budgets.manifest.json | 2 +- scripts/doc-standard.spec.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + 46 files changed, 944 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md create mode 100644 .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md create mode 100644 docs/cookbook/adding-a-remote-api.i18n.yaml create mode 100644 docs/cookbook/adding-a-remote-api.md create mode 100644 docs/cookbook/adding-a-remote-api.zh.md create mode 100644 packages/util/time/README.i18n.yaml create mode 100644 packages/util/time/README.md create mode 100644 packages/util/time/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 53bfd94dd7..35207bb94e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b95e3f0dec56287cbec2586921477284d0489a40 -2026-08-02-typert-remote-method-calls.zh.md: 50f04fd44a06ae3914998f0337fef09c75fe707c +2026-08-02-typert-remote-method-calls.md: 9102d04266626a8692e1d3ec5cb8f529d8c34d13 +2026-08-02-typert-remote-method-calls.zh.md: bf9ec58b28c1e1e833d92d76737890d49f0487a3 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b95e3f0dec..9102d04266 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -6,7 +6,7 @@ English | [中文](2026-08-02-typert-remote-method-calls.zh.md) ## Problem -The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. +The Host API Proxy handled direct method calls, stateful interactions, and Session event streams in one package. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs. @@ -22,7 +22,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T `@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. -`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and Typert lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypertClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry registers the application's forwarded Cordis event source and the Host facts carried by generation readiness; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypertClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -32,7 +32,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and legacy API Proxy fallback | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | | API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -162,9 +162,9 @@ ctx.typert.contexts Host Context resolvers and Client Context binders Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object. -The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that Typert Service. +The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `gateway/lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that Typert Service. -Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. +Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. The Session Controller's `ApiSessionAgentController` configures one shared resolver for the `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns `session/agent-busy`. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. The registry's Host root entry has the complete `TypertRegistryContract` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -254,7 +254,7 @@ interface TypertRemoteNamespace$676f616c73 { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteMap { @@ -262,7 +262,7 @@ interface TypertRemoteMap { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteNamespaceMap { @@ -273,7 +273,7 @@ interface TypertRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } ``` @@ -288,7 +288,9 @@ agentCtx.remote.goals.create(request) The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. -`TypertClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. +Every generated method resolves to `Promise>`: a call reports its outcome in the result's `ok` branch instead of rejecting, and only an assembly fault (arity, an unmounted method, a missing Context adapter) still throws. A consumer branches on `result.ok`, and reads `result.error.code` when it must distinguish failures; the failure vocabulary itself is [one Remote failure class plus a merged code table](2026-08-28-ctx-remote-failure-vocabulary.md). + +`TypertClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. Beside the generated namespaces, the Gateway's client face adds `$mount`, `$on`, `$stream`, and `$host` — the last exposing the connection's fixed Host facts (`home`, `isLoopback`) as plain reads, so a consumer never injects the carrier to learn them. ## Client Typert and the API Gateway Client face @@ -361,7 +363,7 @@ CI and releases use LIB. Moving all repository coverage to LIB is separate follo ## Host Gateway resolution -The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current Typert local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so Typert definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current Typert local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so Typert definitions and business Services may arrive in either order without rescanning every Service on each request or letting arbitrary request paths grow the cache. Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypertLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. @@ -399,9 +401,9 @@ ctx.connection.rpc.intercept( ) ``` -The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback. +The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; an endpoint that neither an exact Fetch route nor the Gateway claims answers 404. -The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is: +The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler matches the pathname against the exact Fetch routes owners registered on the channel, then against the channel's single interceptor — the Gateway — and answers 404 when neither claims it. Every path on the channel reuses the same request/response envelope, rpcId, serialization, trust, and error transport, and a failure carries the shared `{ code, message, details }` data. The current physical mapping is: ```text POST /api// @@ -438,15 +440,15 @@ ctx.remote.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypertLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. +Remote does not define a second-layer `{ ok, value/error }` response on the wire. Successful values and failures use the existing RPC response's `result` directly, and the failure branch carries the shared `{ code, message, details }` data. Owners, resolvers, and the Gateway all raise one class, `RemoteError`, whose code comes from the merged `RemoteErrorDetailsMap`: the Host encodes a structurally identified `RemoteError` onto the wire unchanged — including the Gateway's own `gateway/*` assembly codes and a resolver's `session/not-found` or `session/agent-busy` — and folds only an unclassified throw into `gateway/internal`, keeping its diagnostic in the message. The Client face rebuilds an instance for the `RemoteResult` error branch, so `throw result.error` keeps throw semantics. [The failure-vocabulary Agent Note](2026-08-28-ctx-remote-failure-vocabulary.md) owns the code table, its ownership rules, and why discrimination reads `code` instead of `instanceof`. -The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. Typert endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. Every request on the shared channel, Typert endpoint or exact Fetch route alike, passes Connection's browser authentication and trusted-host policy before dispatch; the Gateway adds no second policy. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types. -The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. +The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches an exact registered path to its route owner, a claimed endpoint to the Gateway, and anything else to 404. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. ## Package boundaries @@ -454,19 +456,19 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - Typert generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - Typert runtime: separately stores the current environment's local reflection and imported Remote contributions. - `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract. -- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; registers the application's forwarded Cordis event source and the Host home carried by generation readiness, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract. +- Connection: owns the single HTTP Server/future WebSocket carrier, the shared `/api` route and its composite FetchHandler, owner-registered exact Fetch routes, the RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. -- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. +- `@deepseek-ai/dsh-api-session-controller`: configures the shared `agent`/`session` lookup and `agent` Host Context resolver, so every Remote endpoint that accepts one of those objects shares one resume and ownership-fence policy. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed by the shared lookup resolver, while subagent-owned identities retain the `session/agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. -The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote. +The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. ## Alternatives considered @@ -486,7 +488,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service. -**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. +**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection compose it from owner-registered exact Fetch routes and the channel's single interceptor. ## Verification @@ -496,11 +498,11 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. -- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. +- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `session/agent-busy` before business invocation. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. - Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. -- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. +- A request that matches neither an exact Fetch route nor a claimed Remote endpoint answers 404 on the same channel, while a withdrawn route stops being served. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 50f04fd44a..bf9ec58b28 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 +Host API Proxy 当时在一个包里同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。 @@ -22,7 +22,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 `@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 -`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 Typert lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 约定,而不导入具体 Gateway 实现。 +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host 事实;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 约定,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -32,7 +32,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截和旧 API Proxy 回退 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -162,9 +162,9 @@ ctx.typert.contexts Host Context resolvers and Client Context binders 每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 -lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 Typert Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 +lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `gateway/lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 Typert Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 +业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。Session Controller 的 `ApiSessionAgentController` 为 `agent`、`session` lookup 和 `agent` Host Context 配置同一个共享 resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回 `session/agent-busy`。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypertRegistryContract` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -254,7 +254,7 @@ interface TypertRemoteNamespace$676f616c73 { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteMap { @@ -262,7 +262,7 @@ interface TypertRemoteMap { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteNamespaceMap { @@ -273,7 +273,7 @@ interface TypertRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } ``` @@ -288,7 +288,9 @@ agentCtx.remote.goals.create(request) Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 -`TypertClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 +每个生成方法都解析为 `Promise>`:调用把结果报告在 `ok` 分支里而不是 reject,只有装配故障(arity、未挂载的方法、缺失的 Context adapter)仍然抛出。消费方按 `result.ok` 分支,需要区分失败时读 `result.error.code`;失败词汇本身是[单一 Remote 失败类加一张合并码表](2026-08-28-ctx-remote-failure-vocabulary.zh.md)。 + +`TypertClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。除生成的 namespace 之外,Gateway client face 还提供 `$mount`、`$on`、`$stream` 与 `$host`——最后这项把连接的固定 Host 事实(`home`、`isLoopback`)作为普通值读取暴露,消费方无需为此注入载体。 ## Client Typert 与 API Gateway Client face @@ -361,7 +363,7 @@ CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工 ## Host Gateway 解析 -Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 Typert local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 Typert definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 Typert local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 Typert definition 与业务 Service 可以按任意顺序到达,同时既不会在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypertLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 @@ -399,9 +401,9 @@ ctx.connection.rpc.intercept( ) ``` -Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。 +Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;既不匹配精确 Fetch route、也不被 Gateway 认领的 endpoint 返回 404。 -Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: +Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 先用 pathname 匹配各 owner 在该 channel 上注册的精确 Fetch route,再匹配该 channel 唯一的 interceptor——即 Gateway——两者都不认领时返回 404。该 channel 上的每条路径复用同一 request/response envelope、rpcId、序列化、trust 与错误传输,失败则携带共享的 `{ code, message, details }` 数据。当前物理映射是: ```text POST /api// @@ -438,15 +440,15 @@ ctx.remote.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypertLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 +Remote 不在 wire 上定义第二层 `{ ok, value/error }` response。成功值与失败都直接使用既有 RPC response 的 `result`,失败分支携带共享的 `{ code, message, details }` 数据。owner、resolver 与 Gateway 抛的都是同一个类 `RemoteError`,其码来自合并后的 `RemoteErrorDetailsMap`:Host 把结构识别出的 `RemoteError` 原样编码上 wire——包括 Gateway 自己的 `gateway/*` 装配码,以及 resolver 的 `session/not-found`、`session/agent-busy`——只把未归类的 throw 折成 `gateway/internal`,并把诊断串留在 message 里。Client face 为 `RemoteResult` 的错误分支重建实例,因此 `throw result.error` 的 throw 语义成立。[失败词汇 Agent Note](2026-08-28-ctx-remote-failure-vocabulary.zh.md) 持有码表、落点规则,以及为什么判别读 `code` 而不用 `instanceof`。 -Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。Typert endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。共享 channel 上的每个请求——无论是 Typert endpoint 还是精确 Fetch route——都先过 Connection 的浏览器认证与 trusted-host 策略再分发;Gateway 不叠加第二套策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。 -Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 +Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 把精确注册路径分发给它的 route owner、把已认领 endpoint 分发给 Gateway,其余一律 404。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 ## 包边界 @@ -454,19 +456,19 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - Typert generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - Typert runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 - `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。 -- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host home,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与其复合 FetchHandler、各 owner 注册的精确 Fetch route、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 -- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 +- `@deepseek-ai/dsh-api-session-controller`:配置共享的 `agent`/`session` lookup 与 `agent` Host Context resolver,因此每个接收这些对象的 Remote endpoint 共用同一套恢复与 ownership fence 策略。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时由该共享 resolver 恢复,subagent-owned identity 保持 `session/agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 -包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。 +包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。 ## Alternatives considered @@ -486,7 +488,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。 -**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 +**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 用各 owner 注册的精确 Fetch route 与该 channel 唯一的 interceptor 组合出它。 ## 验证 @@ -496,11 +498,11 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 -- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 +- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `session/agent-busy`。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 - 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 -- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 +- 既不匹配精确 Fetch route、也不属于已认领 Remote endpoint 的请求在同一 channel 上返回 404,而已撤回的 route 随即停止服务。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index 5868c44f02..0f52ac4e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.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-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: b98c7ee95b61ec00a5cab3106e812a6f17fc0a15 -2026-08-10-unary-apiproxy-remote-migration.zh.md: 74bca8fd72f3e41075f5a44eba116fe127343bb2 +2026-08-10-unary-apiproxy-remote-migration.md: ee93276b08e204b8c10c22c9fbb890a73691c5a9 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 63eb30a1c079c4f5beb3c6aa328ae31f9dbc51b2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md index b98c7ee95b..ee93276b08 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -31,10 +31,10 @@ Simple unary operations live on their natural business Remote owner. The busines | `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | | `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. | -| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | +| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness, and consumers read it as a plain value through `ctx.remote.$host.home` beside `$host.isLoopback`; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | | `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` registers an exact Connection Fetch route and streams the ZIP without a JSON Remote envelope. | -The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. +The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. The resolver raises a `RemoteError` carrying its own code — `session/not-found` or `session/agent-busy` — and the Gateway encodes that code, message, and details onto the wire unchanged, so a lookup refusal stays distinguishable from `gateway/internal` ([failure vocabulary](2026-08-28-ctx-remote-failure-vocabulary.md)). The native path implementation lives in `@deepseek-ai/dsh-native-command`. Settings controllers select Host-owned targets, while Session-aware Clients resolve workspace paths before calling `SessionController`; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index 74bca8fd72..63eb30a1c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -31,10 +31,10 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 | `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | | `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 | -| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | +| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home,消费方通过 `ctx.remote.$host.home` 与并列的 `$host.isLoopback` 以普通值读取;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | | `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` 注册精确的 Connection Fetch 路由,并在没有 JSON Remote envelope 的情况下流式传输 ZIP。 | -共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 +共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。resolver 抛出携带自有码的 `RemoteError`——`session/not-found` 或 `session/agent-busy`——Gateway 把该码、message 与 details 原样编码上 wire,因此 lookup 拒绝与 `gateway/internal` 始终可区分([失败词汇](2026-08-28-ctx-remote-failure-vocabulary.zh.md))。 原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Settings controller 选择 Host 持有的目标,Session-aware Client 则在调用 `SessionController` 前解析 workspace 路径;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml new file mode 100644 index 0000000000..80bdc66709 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.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-28-ctx-remote-failure-vocabulary.md +2026-08-28-ctx-remote-failure-vocabulary.md: 90126d9c23a8f1a1067f5d98f34e24f537774a3a +2026-08-28-ctx-remote-failure-vocabulary.zh.md: c00aa87b30692fed6ae8121e4373f806d063a967 diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md new file mode 100644 index 0000000000..90126d9c23 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md @@ -0,0 +1,88 @@ +# Agent Note: One Remote failure vocabulary for ctx.remote + +Status: implemented + +English | [中文](2026-08-28-ctx-remote-failure-vocabulary.zh.md) + +## Problem + +Every Remote owner package maintained its own failure surface: an `XxxErrorDetailsMap` interface, an `XxxError` union derived from it, and an exit mapping function that translated domain error classes (`UnknownPresetError`, `PresetMountError`, `SessionTitleInvalidError`, and their peers) into a wire failure value. `@deepseek-ai/dsh-typert-protocol` carried two failure classes at once — `TypertRemoteFailure` for a failure an owner reported and `TypertLookupFailure` for one a lookup resolver produced — while `@deepseek-ai/dsh-client-connection` kept a second typed view, `RpcErrorDetailsMap`, that hardcoded domain codes such as `agent-preset-not-found` and `session-not-found` into the carrier. + +One code therefore existed in three places: the owner's table, the carrier's typed view, and whatever union or cast a consumer wrote to narrow it (`result.error as SessionError`). Adding a domain code meant editing all three, and relaying another domain's code meant copying that code into your own table — `SessionErrorDetailsMap` had absorbed five foreign codes this way, across `agent-preset-*`, `subagent-*`, and `workspace-not-found`. + +Failure information was flattened in two places as well. All 17 of the Gateway's own assembly failures (an unmounted method, an ambiguous endpoint, a lookup provider mismatch, a result that fails its codec) reached the wire as `code: 'internal'`, so a client could not separate an assembly fault from a business refusal; owners defensively pre-folded unrelated exceptions into their own domain codes, so a genuine Host bug arrived at the caller as a plausible-looking domain failure. + +Fixed Host facts bypassed `ctx.remote` too: the Host home came from `(ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home`, so every page that needed one fixed fact injected the carrier and understood its generation store. + +## Decision + +`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteGatewayError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult` keeps its shape. + +```text +export class RemoteError extends Error { + readonly isDSHRemoteGatewayError = true as const + constructor(readonly code: Code, message: string, + readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) +} +export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError }[RemoteErrorCode] +export type RemoteResult = { ok: true; value: T } | { ok: false; error: RemoteFailure } +``` + +A failure point throws directly: `throw new RemoteError(code, message, details)`. A domain builds no error-class family and writes no exit mapping function; only the "classify any provider exception" case keeps one `catch`, and inside it `throw new RemoteError(code, messageOf(error), details, { cause: error })`. An existing exception class that an in-process flow still consumes (`ApiSessionCwdConflict` and its peers) stays as a non-exported private class and converts to a `RemoteError` in one line at the exit. + +A code is a `/` string: `session/not-found`, `gateway/cancelled`, `workspace/invalid-path`, `agent-preset/locked`. The prefix follows the wire-namespace style, so the code itself says who owns it, and relaying another domain's code no longer needs an awkward unprefixed name. + +## Code ownership + +A code has exactly one declaration site, and the site follows from both who produces it and who can see the declaration — declaration merging only applies where the augmenting file enters the current program, so the home must be a package every producer already sees: + +- **Carrier codes**: `gateway/bad-request`, `gateway/cancelled`, and `gateway/internal` are declared by the protocol and reachable everywhere. +- **Gateway assembly codes**: the 17 `gateway/*` codes are declared in `packages/api/gateway/src/remote-error-codes.ts` with the uniform `TypertGatewayFaultDetails { endpoint, field? }` details; that module is face-neutral and each face imports it, so both programs see the same entries. +- **Produced by several packages**: when two or more packages throw the same code, the declaration lands in the lowest layer both already depend on. `session/not-found` lands in `@deepseek-ai/dsh-session` (session-controller and workspace-controller both depend on it), and `workspace/not-found` lands in `@deepseek-ai/dsh-workspace` (no dependency edge exists between the two API packages, so the capability package is their only shared layer). +- **Single producer**: a code only one package throws lands in that producer. `subagent/not-found` and `agent-preset/conflict` therefore live in session-controller — it is their only thrower in the repository, and neither the subagent nor the agent-presets table declares them. + +What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem. + +## Discrimination by code + +Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read the marker, never the class. + +Business code usually needs neither function: the `ok: false` branch of `RemoteResult` is already a typed `RemoteFailure`, so `if (result.error.code === 'session/not-found')` narrows `details` to that code's shape with no cast. A site that must propagate the failure writes `throw result.error` — it is a real `Error`, with a working stack and `message`. + +The client plane does not construct `RemoteError`; the one exception is the Gateway's own client face, which rebuilds an instance from wire data in `invoke()` and folds carrier throws at stream boundaries into the same vocabulary. A test double that needs a failure value takes `RemoteError` from `@deepseek-ai/dsh-client-test-runtime` instead of making a client package import the protocol as a value. Assertions match the code (plus details fields where they matter) with `toMatchObject`: `RemoteError` is an `Error`, its own-key set differs from the former literal, and `toEqual` fails on it. + +## Fixed Host facts + +`ctx.remote.$host` exposes two fixed facts: `home: string | undefined` and `isLoopback: boolean`. It is a getter on the Client Remote service reading the connection handle captured at service construction — `home` comes from the ready frame in the generation snapshot (`undefined` before ready), `isLoopback` from the carrier. There is no store, no subscription, and no generation counter. + +Refresh after a reconnect rides the existing signal: the Client Remote emits `connection/reset` when it connects, and a consumer that must re-read listens for that or for its own domain's remote event rather than turning `$host` into a subscribable object. Consumers therefore no longer inject `connection`: the `@deepseek-ai/dsh-client-connection` consumer allowlist shrinks to hmr, frontend-static, bundle/web-app, session-log-export, webworker-runtime, and the gateway and api-remotes assemblies. + +## What the wire carries + +The envelope is unchanged: the wire still carries `{ code, message, details }` data, and `RemoteError` is each side's in-process carrier for it. On the Host, `rpcFailure()` collapses to two branches — a structurally identified `RemoteError` is encoded as-is, everything else folds into `gateway/internal` — and carrier-signal cancellation uses the same vocabulary (the `RemoteInvocationCancelled` class is deleted, and its four throw points raise `RemoteError('gateway/cancelled', …)`). + +Three wire-visible behaviors follow. The Gateway's 17 assembly codes travel as themselves, so a client can handle "method not mounted" separately from a business refusal. Owners do not pre-fold unrelated exceptions: an unclassified throw reaches the Gateway, which folds it into `gateway/internal` once and keeps the diagnostic chain in `message`. A client unary call aborted by its caller answers `gateway/cancelled`, matching the code the Host would have produced even when the local throw wins the race against the wire round-trip. + +The carrier keeps only the open wire shape. `ConnectionRpcFailure` and `ConnectionRpcResult` in `@deepseek-ai/dsh-client-connection` carry no domain-code knowledge, and its `transportError()` produces `gateway/internal`; the only home for the typed view is now the protocol's `RemoteFailure`. + +## Alternatives considered + +**A `RemoteFault` error-class family per domain.** Giving each domain (or each code) its own `Error` subclass reads as more object-oriented, but it splits one fact — the code — across class identity and a field, and cross-realm discrimination has to fall back to the field anyway. Class identity then becomes pure overhead: every domain maintains a subclass, exports it, and explains it in prose, while consumers still branch on `code`. One class plus one code table trades that weight for a single declaration line. + +**`attempt` / `unwrap` / `remoteFailureOf` wrappers at call sites.** A wrapper saves one `if` per call site, but it turns `RemoteResult` from the canonical shape into "first pass it through a library function," and both styles then coexist indefinitely; `unwrap` additionally turns "failure is a normal result" back into an exception flow, against the Remote face's contract of never rejecting. The `remoteErrorOf` that survives serves the mechanism layer and test assertions only — business code holds either a typed `result.error` or a failure it threw itself. + +**A `host/updated` event with a subscribed `$host` store.** A subscription would refresh automatically when the Host home changes, but `home` and `isLoopback` are fixed for the lifetime of one connection, so a store, generation, and subscription lifecycle would tax every page that only wants one read. Reconnection already has a signal (`connection/reset`) and business invalidation rides each domain's remote event, so fixed facts stay plain reads. + +**Putting local, non-wire failures in the code table.** ui-goal's `no-current-goal` never crosses a process boundary; admitting it would mix entries only one client package cares about into a shared vocabulary and would suggest it has wire semantics. Local failures keep their own local types, and the code table describes the Remote vocabulary alone. + +## Consequences + +Adding a domain code is one declaration merge plus one throw: no mapping function, error class, and carrier typed view to keep in step. The cost is that the home now requires a judgment — it must be reachable from every producer — and that judgment only surfaces once a second producer appears; `workspace/not-found` moved from workspace-controller to the capability package exactly that way, which also gave `@deepseek-ai/dsh-workspace` a type-only protocol dependency. + +Prefixing the code strings changes the wire strings wholesale, so codes embedded in connection fixtures, assertions on both the Host and Client sides, and spec-local declarations all move in one pass. The pre-release stance accepts that single cut; the same rename after a release would need a compatibility window. + +The type of `details` follows from the code, so a code-and-details mismatch is rejected at compile time. The other face of that is every throw site having to supply the code's required detail fields: the protocol makes `issues` optional on `gateway/bad-request` precisely so a business validation point with no codec issues still writes `{}`. + +`RemoteError` is an `Error`, so it keeps `message` and `cause` through any logger and through `errorChain()`; but `cause` holds only in-process, and the wire carries exactly `code`, `message`, and `details`. Cross-realm discrimination always reads the structural marker, and any new transport (a worker, a bundle split) must carry that marker or an equivalent marker frame across, or failure values degrade into plain `Error`s. + +Consumer signatures for Remote methods are uniformly `Promise>`, matching the generated projection described in [the method-call surface](2026-08-02-typert-remote-method-calls.md); the ledger for the unary endpoints is [the unary endpoint migration](2026-08-10-unary-apiproxy-remote-migration.md). diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md new file mode 100644 index 0000000000..c00aa87b30 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md @@ -0,0 +1,88 @@ +# Agent Note: One Remote failure vocabulary for ctx.remote + +Status: implemented + +[English](2026-08-28-ctx-remote-failure-vocabulary.md) | 中文 + +## Problem + +每个 Remote owner 包各自维护一套失败面:一个 `XxxErrorDetailsMap` 接口、由它派生的 `XxxError` union,以及一个出口映射函数,把域内错误类(`UnknownPresetError`、`PresetMountError`、`SessionTitleInvalidError` 等)翻译成 wire 失败值。`@deepseek-ai/dsh-typert-protocol` 同时携带两个失败类——owner 主动上报用 `TypertRemoteFailure`,lookup resolver 产生的用 `TypertLookupFailure`——而 `@deepseek-ai/dsh-client-connection` 又保留了第二份 typed 视图 `RpcErrorDetailsMap`,把 `agent-preset-not-found`、`session-not-found` 这类域码硬编码进载体层。 + +于是一个码同时存在三处:owner 的表、载体的 typed 视图、以及消费方为窄化而写的 union 或 cast(`result.error as SessionError`)。新增一个域码要改三处,跨域转述一个别人的码则要把对方的码复制进自己的表——`SessionErrorDetailsMap` 就收编了 `agent-preset-*`、`subagent-*`、`workspace-not-found` 五个他域码。 + +失败信息也在两处被压平。Gateway 自己的 17 个装配失败(未挂载的方法、歧义 endpoint、lookup provider 不匹配、结果未过 codec 等)一律以 `code: 'internal'` 上 wire,client 无法把装配 bug 与业务拒绝区分开;owner 又出于防御把无关异常预折成自己的域码,于是一个真正的 Host bug 会以一个看起来合理的域失败到达调用方。 + +Host 固定事实同样绕过了 `ctx.remote`:Host home 取自 `(ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home`,任何只需要一条固定事实的页面都得注入载体并理解它的 generation store。 + +## Decision + +`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteGatewayError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult` 形状不变。 + +```text +export class RemoteError extends Error { + readonly isDSHRemoteGatewayError = true as const + constructor(readonly code: Code, message: string, + readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) +} +export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError }[RemoteErrorCode] +export type RemoteResult = { ok: true; value: T } | { ok: false; error: RemoteFailure } +``` + +失败点直接 `throw new RemoteError(code, message, details)`。域内不再建错误类家族,也不再写出口映射函数;只有「把任意 provider 异常归类」这一种场景保留一个 `catch`,并在其中 `throw new RemoteError(code, messageOf(error), details, { cause: error })`。进程内仍需消费的既有异常类(`ApiSessionCwdConflict` 等)保留为不导出的私有类,在出口一行转成 `RemoteError`。 + +码是 `<语义域>/<理由>` 形式的字符串:`session/not-found`、`gateway/cancelled`、`workspace/invalid-path`、`agent-preset/locked`。前缀与 wire namespace 同风格,读者从码本身就能看出它属于谁,跨域转述时也不再需要一个别扭的无前缀名。 + +## Code ownership + +一个码只有一个声明处,落点由「谁生产它」和「声明对谁可达」共同决定——声明合并只在增补文件进入当前 program 时生效,所以正家必须是每个生产者都能看见的包: + +- **载体码**:`gateway/bad-request`、`gateway/cancelled`、`gateway/internal` 由 protocol 声明,人人可达。 +- **Gateway 装配码**:17 个 `gateway/*` 由 `packages/api/gateway/src/remote-error-codes.ts` 声明,details 统一为 `TypertGatewayFaultDetails { endpoint, field? }`;该模块 face-neutral,Host 与 Client 两面各自 import,因此两个 program 看到同一批条目。 +- **跨包共产**:两个及以上不同包抛同一个码时,声明落到双方都已依赖的最低层。`session/not-found` 落 `@deepseek-ai/dsh-session`(session-controller 与 workspace-controller 都依赖它),`workspace/not-found` 落 `@deepseek-ai/dsh-workspace`(session-controller 与 workspace-controller 之间没有依赖边,能力包是唯一共同下层)。 +- **单一生产者**:只有一个包抛的码落生产者包。`subagent/not-found` 与 `agent-preset/conflict` 因此落 session-controller——全仓只有它抛这两个码,subagent 与 agent-presets 的码表里都没有它们。 + +共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。 + +## Discrimination by code + +判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看标记,不看类。 + +业务代码通常连这两个函数都不需要:`RemoteResult` 的 `ok: false` 分支已经是类型化的 `RemoteFailure`,`if (result.error.code === 'session/not-found')` 就把 `details` 窄化到该码的形状,无需 cast。需要向上抛的站点直接 `throw result.error`——它是真 `Error`,栈与 `message` 都成立。 + +client 面不构造 `RemoteError`:唯一例外是 Gateway 的 client face 本身,它在 `invoke()` 里按 wire 数据重建实例、在流边界把载体 throw 折进同一词汇。测试替身要构造失败值时从 `@deepseek-ai/dsh-client-test-runtime` 取 `RemoteError`,而不是让 client 包值引入 protocol。断言用 `toMatchObject` 判 code(必要时加 details 字段):`RemoteError` 是 `Error`,own key 集合与旧字面量不同,`toEqual` 会失败。 + +## Fixed Host facts + +`ctx.remote.$host` 暴露两条固定事实:`home: string | undefined` 与 `isLoopback: boolean`。它是 Client Remote service 上的 getter,读的是 service 构造期取得的 connection 句柄——`home` 来自 generation 快照的 ready frame(ready 之前是 `undefined`),`isLoopback` 来自载体。没有 store、没有订阅、没有 generation 计数器。 + +重连后的刷新走既有信号:Client Remote 在连上时 emit `connection/reset`,需要重取的消费方监听它或各域自己的 remote event,而不是让 `$host` 变成一个可订阅对象。因此消费方不再注入 `connection`:`@deepseek-ai/dsh-client-connection` 的消费白名单收缩到 hmr、frontend-static、bundle/web-app、session-log-export、webworker-runtime、gateway 与 api-remotes 装配。 + +## What the wire carries + +envelope 不变:wire 上仍是 `{ code, message, details }` 数据,`RemoteError` 是两端各自的进程内载体。Host 侧 `rpcFailure()` 收敛为两分支——结构识别出的 `RemoteError` 原样编码,其余折成 `gateway/internal`;载体信号取消也走同一词汇(`RemoteInvocationCancelled` 类整体删除,四个 throw 点改抛 `RemoteError('gateway/cancelled', …)`)。 + +三条 wire 可见行为随之确定。Gateway 的 17 个装配码按语义上 wire,client 因此能把「方法未挂载」与「业务拒绝」分开处理。owner 不预折无关异常:未归类的 throw 交给 Gateway 折一次 `gateway/internal`,诊断串保留在 `message` 里。client 一元调用被调用方 abort 时答 `gateway/cancelled`,即使本地 throw 抢在 wire 往返之前赢得竞争,也与 Host 会给出的码一致。 + +载体层只保留开放的 wire 形状。`@deepseek-ai/dsh-client-connection` 的 `ConnectionRpcFailure`/`ConnectionRpcResult` 不含任何域码知识,其 `transportError()` 产出 `gateway/internal`;typed 视图的正家从此只有 protocol 的 `RemoteFailure`。 + +## Alternatives considered + +**每域一套 `RemoteFault` 错误类家族。** 让每个域(或每个码)有自己的 `Error` 子类,看起来更 OO,但它把「码」这一条信息拆成了类身份加字段两处,跨 realm 又只能退回判字段——于是类身份成为纯粹的负担:每个域要维护子类、导出它、在文档里解释它,而消费方仍然只能判 code。单类加一张码表把这份重量换成了一行声明。 + +**在调用点加 `attempt` / `unwrap` / `remoteFailureOf` 包装函数。** 包装能让调用点少写一个 `if`,但它把 `RemoteResult` 这个 canonical 形状变成了「先过一层库函数」,两种风格会长期并存;`unwrap` 还会把「失败是正常结果」重新变成异常流,与 Remote 面不 reject 的契约背道而驰。被保留的 `remoteErrorOf` 只服务机制层与测试断言,业务代码拿到的要么是已类型化的 `result.error`、要么是自己抛的,不需要它。 + +**`host/updated` 事件加订阅式 `$host` store。** 订阅能在 Host home 变化时自动刷新,但 home 与 isLoopback 在一条连接内是固定事实,为它引入 store、generation 与订阅生命周期,等于让每个只想读一次的页面都承担一套状态管理。重连是已有信号(`connection/reset`),业务失效走各域 remote event,固定事实保持普通值读取。 + +**把不上 wire 的本地失败也纳入码表。** 例如 ui-goal 的 `no-current-goal`:它从不跨进程,纳入码表会让共享词汇混入只有一个 client 包关心的条目,还会误导读者以为它有 wire 语义。本地失败保持各自的本地类型,码表只描述 Remote 词汇。 + +## Consequences + +新增一个域码是一处 declaration merging 加一个 throw:不再有映射函数、错误类、载体 typed 视图三处联动。代价是落点需要判断——正家必须对每个生产者可达,而这条判断只有在真的出现第二个生产者时才显现;`workspace/not-found` 就是这样从 workspace-controller 迁到能力包的,并为此给 `@deepseek-ai/dsh-workspace` 加了一条 type-only 的 protocol 依赖。 + +码字符串带前缀后,wire 字符串整体变化,connection fixture 内嵌的码、host 与 client 两侧断言、spec 本地 declare 一次性同步。发布前阶段接受这次一波切;发布后同样的改名需要一个兼容期。 + +`details` 的类型由码决定,因此码与 details 的搭配错误在编译期就被拒。反面是每个抛点都要给全 details 的必填字段:protocol 把 `gateway/bad-request` 的 `issues` 设为可选,正是为了让没有 codec issues 的业务校验点仍然只写 `{}`。 + +`RemoteError` 是 `Error`,所以它进任何日志与 `errorChain()` 都保留 `message` 与 `cause`;但 `cause` 只在进程内成立,wire 上只有 `code`、`message`、`details` 三个字段。跨 realm 的判别永远读结构标记,任何新增的传输(worker、bundle 分片)都必须把标记或等价的 marker 帧带过去,否则失败值会退化为普通 `Error`。 + +Remote 方法的消费端签名统一为 `Promise>`,与[方法调用面](2026-08-02-typert-remote-method-calls.zh.md)描述的生成投影一致;一元调用的迁移账本见[一元端点迁移](2026-08-10-unary-apiproxy-remote-migration.zh.md)。 diff --git a/docs/cookbook/adding-a-remote-api.i18n.yaml b/docs/cookbook/adding-a-remote-api.i18n.yaml new file mode 100644 index 0000000000..89781a29df --- /dev/null +++ b/docs/cookbook/adding-a-remote-api.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 docs/cookbook/adding-a-remote-api.md +adding-a-remote-api.md: 0f89101e9bc3ff961d465c109f033150e93e04f6 +adding-a-remote-api.zh.md: 0c5a57b539ecd98cb88790260a3fb8e7b41afff6 diff --git a/docs/cookbook/adding-a-remote-api.md b/docs/cookbook/adding-a-remote-api.md new file mode 100644 index 0000000000..0f89101e9b --- /dev/null +++ b/docs/cookbook/adding-a-remote-api.md @@ -0,0 +1,197 @@ +# Cookbook: adding a Remote API + +English | [中文](adding-a-remote-api.zh.md) + +Adding or changing a `ctx.remote` endpoint takes the five steps on this page: declare the method, declare its failures, register it on the package, consume it on the Client, and test it. Decorator semantics, lookup resolution, the generation pipeline, and the `/api` route are the mechanism and belong to the [API Gateway reference](../api-gateway.md); this page gives the action for each step and the conventions it must satisfy. Why the programming interface looks like this is in the [Typert Remote method calls Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md), and why a failure is one `RemoteError` plus a code table is in the [failure vocabulary Agent Note](../../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md). + +## 1. Declare the API + +The owner is a Host-side Cordis service: extend `TypertRemoteService` so the service key and the wire namespace are bound together, then mark the exposed methods with `@Remote`. Mark the business method itself when its signature already satisfies the wire conventions; write a `remoteExport*` adapter only when the shape has to change (adding `signal`, reordering parameters, exporting another name), and let that adapter call the unrenamed business method. Lookup objects (`Agent`, `Session`) may only occupy top-level parameter positions, and a method that supports cooperative cancellation takes `signal: AbortSignal` as its final parameter. + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +/** One stored note as a Client reads it. */ +export interface NoteRow { + readonly noteId: string + readonly title: string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + notesController: NotesController + } +} + +export class NotesController extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'notesController', { namespace: 'notes' }) + } + + /** + * @param agent - lookup parameter the Gateway resolves from its wire identity. + * @param signal - carrier cancellation, always the final parameter. + * @returns the notes this Agent's session owns. + */ + @Remote('list') + async remoteExportList(agent: Agent, signal: AbortSignal): Promise { + return await this.list(agent, signal) + } + + /** The in-process API the adapter above delegates to, unchanged by it. */ + async list(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return await Promise.resolve([{ noteId: `${agent.id}-1`, title: 'draft' }]) + } +} +``` + +## 2. Declare the failures + +A Remote failure is one class, `RemoteError`: merge the domain codes into `RemoteErrorDetailsMap` through declaration merging and `throw new RemoteError(code, message, details)` at the failure point. Do not build a family of domain error classes, and do not write an exit-mapping function; an exception unrelated to this endpoint is not pre-classified, because the Gateway folds it into `gateway/internal`. Write a `catch` only to classify an arbitrary provider exception as one domain code, and attach the original exception as `cause`. + +A code reads `/`, and its declaration has four placement rules: + +- One producer only: declare it in the producing package, next to the throw. +- Several packages produce it: declare it in the lowest domain package both depend on (`session/not-found` in `core/session`, `workspace/not-found` in `dsh-workspace`). +- The carrier codes `gateway/bad-request`, `gateway/cancelled`, and `gateway/internal` are declared in protocol, and the Gateway infrastructure codes in gateway — use them, never copy them. +- A local failure that never crosses the wire stays out of the code table; express it with the caller's own type. + +```ts +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No stored note carries that id. */ + 'note/not-found': { readonly noteId: string } + /** The store refused an otherwise valid write. */ + 'note/rejected': { readonly noteId: string } + } +} + +declare const stored: ReadonlyMap +declare function persist(noteId: string, title: string): Promise + +export async function rename(noteId: string, title: string): Promise { + if (!stored.has(noteId)) { + throw new RemoteError('note/not-found', `no note "${noteId}"`, { noteId }) + } + try { + await persist(noteId, title) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new RemoteError('note/rejected', message, { noteId }, { cause: error }) + } +} +``` + +## 3. Register it on the package + +`@Remote` must live in a Loader entry plugin package; when the owner is an abstract seam, the controller goes in the matching package under `packages/api/`. The manifest gains the two generated entries and the protocol peer dependency, while on the Client side the `@deepseek-ai/dsh-api-remotes` assembly mounts the contribution and re-exports the type vocabulary that consumers need. Which generated artifact each entry points at, and how the generation pipeline is ordered, are in the [API Gateway reference](../api-gateway.md). + +```json +{ + "exports": { + "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" }, + "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" } + }, + "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, + "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" } +} +``` + +Rerun `pnpm run build:lib` after changing a signature, the code table, the namespace, or an export name, because that is what hands the Client its new declarations and codecs; changing only an implementation body needs no regeneration. + +## 4. Consume it on the Client + +The calling plugin declares both `remote` and `remote.` in its `inject`, and the call site writes `ctx.remote..(...)` directly: no `Pick` narrowing, no hand-written method signature, no wire relay object. The result is a `RemoteResult`, so branch on `if (!result.ok)` in place and discriminate by `code` rather than `instanceof` — a code branch narrows `details` on its own. An exception-flow site writes `throw result.error` (it is a real Error); whoever catches it uses `isRemoteFailure` to tell a Remote failure from a local defect and rethrows the defect. Do not write a defensive catch: a Remote call does not reject, and an assembly mistake should crash. + +Fixed Host facts come from `ctx.remote.$host`: `home` and `isLoopback` are plain reads with no subscription and no generation counter, and `home` is `undefined` until the first ready frame. Refresh after a reconnect through `ctx.on('connection/reset')` or a domain's own remote event. When the caller aborts a unary call, the outcome is `gateway/cancelled` on the error branch rather than a throw. + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' + +export const inject = ['remote', 'remote.notes'] + +declare const ctx: Context + +/** Store-side read: the error branch is handled where the code is meaningful. */ +export async function noteTitles(): Promise { + const result = await ctx.remote.notes.list() + if (!result.ok) { + if (result.error.code === 'note/not-found') return [] + throw result.error + } + return result.value.map(row => row.title) +} + +/** Action-side: a Remote failure becomes copy; a local fault keeps crashing. */ +export async function renderTitles(): Promise { + try { + return (await noteTitles()).join(', ') + } catch (error: unknown) { + if (!isRemoteFailure(error)) throw error + return `unavailable (${error.code})` + } +} + +/** Fixed Host facts as plain reads. */ +export function hostLabel(): string { + const { home, isLoopback } = ctx.remote.$host + return home ?? (isLoopback ? 'local host' : 'remote host') +} +``` + +## 5. Test it + +On the owner side, assert the code that was thrown: recover the failure with `remoteErrorOf` after catching, then compare `code` and the details fields you care about with `toMatchObject` — never deep-compare the error object with `toEqual`, and never assert `instanceof`. + +```ts +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { expect, it } from 'vitest' + +declare function rename(noteId: string, title: string): Promise + +it('refuses an unknown note before writing', async () => { + const failure = await rename('n-404', 'fresh title').catch((error: unknown) => error) + + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'note/not-found', + details: { noteId: 'n-404' }, + }) +}) +``` + +A Client-side double returns real instances: take the `RemoteError` and `TestRemote` value imports from `@deepseek-ai/dsh-client-test-runtime`, because a value import from the `api-remotes` facade would load the unbuilt assembly chain. `TestRemote.$host` is a plain field a spec assigns directly. + +```ts ignore-check +import { Context } from '@deepseek-ai/cordis' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { expect, it } from 'vitest' + +it('renders the failure code the Host reported', async () => { + const ctx = new Context() + const remote = new TestRemote(ctx, { + notes: { + list: () => Promise.resolve({ + ok: false as const, + error: new RemoteError('note/not-found', 'no note "n-404"', { noteId: 'n-404' }), + }), + }, + }) + remote.$host = { home: '/home/fixture', isLoopback: true } + + await expect(ctx.remote.notes.list()).resolves.toMatchObject({ error: { code: 'note/not-found' } }) +}) +``` + +## Verify + +1. `pnpm run build:lib`: mandatory once a signature, the code table, the namespace, or an export name changed, because it produces the Client declarations and codecs. +2. `pnpm run typecheck`: both the Host and the Client program, where a code merged into an unreachable package turns red. +3. Run both sides' specs by name: `npx vitest run `. +4. Add a recorded-session snapshot when the endpoint reaches a product-visible surface, per the [testing policy](../testing.md). diff --git a/docs/cookbook/adding-a-remote-api.zh.md b/docs/cookbook/adding-a-remote-api.zh.md new file mode 100644 index 0000000000..0c5a57b539 --- /dev/null +++ b/docs/cookbook/adding-a-remote-api.zh.md @@ -0,0 +1,197 @@ +# 实操手册:新增一个 Remote API + +[English](adding-a-remote-api.md) | 中文 + +新增或改动一个 `ctx.remote` 端点按本页五步走:声明方法、声明失败、在包上注册、在 Client 消费、写测试。decorator 语义、lookup 解析、生成管线与 `/api` 路由属于机制,由 [API Gateway 参考](../api-gateway.zh.md)负责;本页给的是每一步的动作与必须遵守的约定。为什么是这套编程面,见 [Typert Remote 方法调用 Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md);为什么失败面是单个 `RemoteError` 加一张码表,见[失败词汇 Agent Note](../../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md)。 + +## 1. 声明 API + +owner 是一个 Host 侧 Cordis 服务:继承 `TypertRemoteService` 把 service 键与 wire namespace 一起绑定,再用 `@Remote` 标注对外暴露的方法。业务方法的签名若已符合 wire 约定就直接标注它本身;只有形态需要调整(补 `signal`、换参数顺序、换导出名)才写一个 `remoteExport*` adapter,由它调用不改名的业务方法。lookup 对象(`Agent`、`Session`)只能占顶层参数位,支持协作式取消的方法把 `signal: AbortSignal` 放在最后一位。 + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +/** One stored note as a Client reads it. */ +export interface NoteRow { + readonly noteId: string + readonly title: string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + notesController: NotesController + } +} + +export class NotesController extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'notesController', { namespace: 'notes' }) + } + + /** + * @param agent - lookup parameter the Gateway resolves from its wire identity. + * @param signal - carrier cancellation, always the final parameter. + * @returns the notes this Agent's session owns. + */ + @Remote('list') + async remoteExportList(agent: Agent, signal: AbortSignal): Promise { + return await this.list(agent, signal) + } + + /** The in-process API the adapter above delegates to, unchanged by it. */ + async list(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return await Promise.resolve([{ noteId: `${agent.id}-1`, title: 'draft' }]) + } +} +``` + +## 2. 声明失败 + +Remote 失败只有一个类 `RemoteError`:域码经 declaration merging 进 `RemoteErrorDetailsMap`,失败点直接 `throw new RemoteError(code, message, details)`。不要建域异常类家族,也不要写出口映射函数;与本端点无关的异常不预先归类,Gateway 会兜底折成 `gateway/internal`。只有"把任意 provider 异常归为一个域码"这一种场景才写 `catch`,并把原始异常挂在 `cause` 上。 + +码名是 `<域>/<理由>`,声明落点四条: + +- 只有一个生产者:声明落生产者包,紧挨抛出点。 +- 多个包共同生产:落双方共同依赖的最低层域包(`session/not-found` 在 `core/session`,`workspace/not-found` 在 `dsh-workspace`)。 +- 载体码 `gateway/bad-request`、`gateway/cancelled`、`gateway/internal` 已在 protocol 声明,Gateway 基础设施码已在 gateway 声明——直接用,不要复制。 +- 不上 wire 的本地失败不进码表,用调用方自己的类型表达。 + +```ts +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No stored note carries that id. */ + 'note/not-found': { readonly noteId: string } + /** The store refused an otherwise valid write. */ + 'note/rejected': { readonly noteId: string } + } +} + +declare const stored: ReadonlyMap +declare function persist(noteId: string, title: string): Promise + +export async function rename(noteId: string, title: string): Promise { + if (!stored.has(noteId)) { + throw new RemoteError('note/not-found', `no note "${noteId}"`, { noteId }) + } + try { + await persist(noteId, title) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new RemoteError('note/rejected', message, { noteId }, { cause: error }) + } +} +``` + +## 3. 在包上注册 + +`@Remote` 必须落在一个 Loader entry 插件包里;owner 是抽象 seam 时把控制器放进 `packages/api/` 下的对应包。包清单要补两个生成入口与 protocol 的 peer 依赖,Client 侧则由 `@deepseek-ai/dsh-api-remotes` 的 assembly 挂载该贡献并按需转口类型词汇。两个入口分别指向哪个生成产物、生成管线如何排序,见 [API Gateway 参考](../api-gateway.zh.md)。 + +```json +{ + "exports": { + "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" }, + "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" } + }, + "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, + "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" } +} +``` + +改动了签名、码表、namespace 或导出名之后重跑 `pnpm run build:lib`,Client 才拿得到新的声明与 codec;只改实现体不需要重新生成。 + +## 4. 在 Client 消费 + +调用插件在 `inject` 里同时声明 `remote` 与 `remote.`,调用点直写 `ctx.remote..(...)`:不要用 `Pick` 窄化、不要手写方法签名、不要造 wire 中转对象。结果是 `RemoteResult`,就地 `if (!result.ok)` 分支,判 `code` 而不是 `instanceof`——code 分支会自动窄化 `details`。异常流的站点写 `throw result.error`(它是真 Error);接住它的上层用 `isRemoteFailure` 区分 Remote 失败与本地缺陷,本地缺陷继续往上抛。不要写防御性 catch:Remote 调用不 reject,装配错误就该炸。 + +Host 的固定事实读 `ctx.remote.$host`:`home` 与 `isLoopback` 是普通值读取,没有订阅也没有 generation 计数器,`home` 在第一帧 ready 之前是 `undefined`;重连后的刷新走 `ctx.on('connection/reset')` 或各域自己的 remote 事件。调用方 abort 掉一次一元调用时,结果落在错误分支上的 `gateway/cancelled`,而不是抛出。 + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' + +export const inject = ['remote', 'remote.notes'] + +declare const ctx: Context + +/** Store-side read: the error branch is handled where the code is meaningful. */ +export async function noteTitles(): Promise { + const result = await ctx.remote.notes.list() + if (!result.ok) { + if (result.error.code === 'note/not-found') return [] + throw result.error + } + return result.value.map(row => row.title) +} + +/** Action-side: a Remote failure becomes copy; a local fault keeps crashing. */ +export async function renderTitles(): Promise { + try { + return (await noteTitles()).join(', ') + } catch (error: unknown) { + if (!isRemoteFailure(error)) throw error + return `unavailable (${error.code})` + } +} + +/** Fixed Host facts as plain reads. */ +export function hostLabel(): string { + const { home, isLoopback } = ctx.remote.$host + return home ?? (isLoopback ? 'local host' : 'remote host') +} +``` + +## 5. 测试 + +owner 侧断言抛出的码:捕获后用 `remoteErrorOf` 取出失败,再用 `toMatchObject` 比对 `code` 与需要的 `details` 字段——不要用 `toEqual` 深比对错误对象,也不要断言 `instanceof`。 + +```ts +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { expect, it } from 'vitest' + +declare function rename(noteId: string, title: string): Promise + +it('refuses an unknown note before writing', async () => { + const failure = await rename('n-404', 'fresh title').catch((error: unknown) => error) + + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'note/not-found', + details: { noteId: 'n-404' }, + }) +}) +``` + +Client 侧的替身返回真实例:`RemoteError` 与 `TestRemote` 的值 import 一律取自 `@deepseek-ai/dsh-client-test-runtime`,因为从 `api-remotes` facade 值 import 会拉起尚未构建的装配链。`TestRemote.$host` 是普通字段,spec 直接赋值即可。 + +```ts ignore-check +import { Context } from '@deepseek-ai/cordis' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { expect, it } from 'vitest' + +it('renders the failure code the Host reported', async () => { + const ctx = new Context() + const remote = new TestRemote(ctx, { + notes: { + list: () => Promise.resolve({ + ok: false as const, + error: new RemoteError('note/not-found', 'no note "n-404"', { noteId: 'n-404' }), + }), + }, + }) + remote.$host = { home: '/home/fixture', isLoopback: true } + + await expect(ctx.remote.notes.list()).resolves.toMatchObject({ error: { code: 'note/not-found' } }) +}) +``` + +## 验证 + +1. `pnpm run build:lib`:签名、码表、namespace 或导出名变过就必须重跑,Client 声明与 codec 由它产出。 +2. `pnpm run typecheck`:Host 与 Client 两个 program 都过一遍,码表的 merge 落点错了会在这里红。 +3. 点名跑两侧 spec:`npx vitest run `。 +4. 端点属于产品可见面时补一条录制会话快照,规则见[测试策略](../testing.zh.md)。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 498532ed69..0a8dc61e44 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 20734175cc6b853ae7eeb18e6d5fc91a97cdbe0d -typert.zh.md: e900011f24a5a232ab8764501594b563f320e090 +typert.md: 0f3d2b1afdc1b7713402abc5885dc9551e16ac14 +typert.zh.md: a3c06489707e18433ed9018363c57ec76732e606 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 20734175cc..0f3d2b1afd 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -139,7 +139,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, ordinary exceptions are folded by the RPC adapter into the transport's `internal` error code, and existing RPC errors carried by lookup policy through `TypertLookupFailure` are returned unchanged. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -158,23 +158,23 @@ interface InvokeRemoteRequest { ```ts type-equiv /** Stable infrastructure and boundary failures emitted before or after business execution. */ type TypertGatewayErrorCode = - | 'ambiguous-endpoint' - | 'arguments-invalid' - | 'binding-invalid' - | 'context-failed' - | 'context-not-found' - | 'context-unavailable' - | 'definition-unavailable' - | 'input-invalid' - | 'invocation-unavailable' - | 'lookup-failed' - | 'lookup-not-found' - | 'lookup-unavailable' - | 'method-unavailable' - | 'provider-mismatch' - | 'result-invalid' - | 'service-unavailable' - | 'signature-invalid' + | 'gateway/ambiguous-endpoint' + | 'gateway/arguments-invalid' + | 'gateway/binding-invalid' + | 'gateway/context-failed' + | 'gateway/context-not-found' + | 'gateway/context-unavailable' + | 'gateway/definition-unavailable' + | 'gateway/input-invalid' + | 'gateway/invocation-unavailable' + | 'gateway/lookup-failed' + | 'gateway/lookup-not-found' + | 'gateway/lookup-unavailable' + | 'gateway/method-unavailable' + | 'gateway/provider-mismatch' + | 'gateway/result-invalid' + | 'gateway/service-unavailable' + | 'gateway/signature-invalid' ``` ```ts type-equiv diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index e900011f24..a3c0648970 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -139,7 +139,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,普通异常由 RPC 适配器归并为传输层的 `internal` 错误码,lookup 策略通过 `TypertLookupFailure` 携带的既有 RPC error 则原样返回。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -158,23 +158,23 @@ interface InvokeRemoteRequest { ```ts type-equiv /** Stable infrastructure and boundary failures emitted before or after business execution. */ type TypertGatewayErrorCode = - | 'ambiguous-endpoint' - | 'arguments-invalid' - | 'binding-invalid' - | 'context-failed' - | 'context-not-found' - | 'context-unavailable' - | 'definition-unavailable' - | 'input-invalid' - | 'invocation-unavailable' - | 'lookup-failed' - | 'lookup-not-found' - | 'lookup-unavailable' - | 'method-unavailable' - | 'provider-mismatch' - | 'result-invalid' - | 'service-unavailable' - | 'signature-invalid' + | 'gateway/ambiguous-endpoint' + | 'gateway/arguments-invalid' + | 'gateway/binding-invalid' + | 'gateway/context-failed' + | 'gateway/context-not-found' + | 'gateway/context-unavailable' + | 'gateway/definition-unavailable' + | 'gateway/input-invalid' + | 'gateway/invocation-unavailable' + | 'gateway/lookup-failed' + | 'gateway/lookup-not-found' + | 'gateway/lookup-unavailable' + | 'gateway/method-unavailable' + | 'gateway/provider-mismatch' + | 'gateway/result-invalid' + | 'gateway/service-unavailable' + | 'gateway/signature-invalid' ``` ```ts type-equiv diff --git a/packages/AGENTS.md b/packages/AGENTS.md index af47b16740..e1a22cabd1 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,6 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). +- **Remote failures are one `RemoteError` plus a merged code table** (`RemoteErrorDetailsMap`, `/`): no domain error classes or mapping functions; discriminate by `code`, never `instanceof`; call `ctx.remote` directly without narrowing or wrappers; client `src` never constructs `RemoteError` ([how-to](../docs/cookbook/adding-a-remote-api.md), [rationale](../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md)). [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index f1d957d143..dfaa6be5bf 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: d10eed46d5534a2459995bd687942d799f009c7b -README.zh.md: 310b44cfe633758089771cda4040679c09373ec2 +README.md: 509f1108a8f940b93b9555399ec59c26325fe4b8 +README.zh.md: 116969c9f04d7791475ff63d8b4602e96c2aaa5b diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index d10eed46d5..509f1108a8 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -28,7 +28,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host 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 returns 404 for unclaimed requests unless an exact Fetch route owns them. 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. +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 returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. @@ -43,7 +43,11 @@ Host composition can register one application event source through `registerRemo Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. -`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. +Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. + +`ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it. + +`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. `ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. @@ -62,7 +66,7 @@ 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. +- The Connection adapter answers `gateway/internal` with empty details for dispatch failures and unclassified exceptions; a `RemoteError` thrown by an owner or by Gateway itself crosses the wire with its own code, message, and details. Its `cause` chain and the `TypertGatewayError` subclass identity survive only for 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. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 310b44cfe6..116969c9f0 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" 严格模式从 `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,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。因策略而拒绝的 resolver——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 @@ -43,7 +43,11 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source 每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 +每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context adapter。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。 + +`ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是 store——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。 + +`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 `ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 @@ -62,7 +66,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source -- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypertLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- Connection 适配器对分发故障与未归类异常答以 `gateway/internal`,且不附带详细信息;拥有方或 Gateway 自己抛出的 `RemoteError` 带着自有码、message 与 details 过线。其 `cause` 链与 `TypertGatewayError` 子类身份只对同进程调用方留存。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 43991614b9..4521c5c219 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/remotes/README.md -README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 -README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 +README.md: 228bd53024a61c935e84d6939a7e8ac2873cd7ea +README.zh.md: c8581bd72909602a1bb5509455dc5ed2a195b66f diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f855bd1cd..228bd53024 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,6 +29,8 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos The Client assembly mounts Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, Session Controller, and Workspace Controller contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the api-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). + This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ----- diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index edad8d735d..c8581bd729 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client 组合挂载 Commands、凭据、settings、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 实现的边。 +本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域的浏览器安全载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 api 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 + 本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和唯一的 Host Cordis event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 ----- diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index 3f837582cb..dc501bba2a 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/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/workspace-controller/README.md -README.md: 731a6331e2a19991921c022759f6bbd971cef525 -README.zh.md: f46c78b7b300f26983eee94d3dfdb488d236116e +README.md: d1ce90d09662f1e39d2da5f7fe1b8276f6ad6150 +README.zh.md: e0b7109275464ddb4c9430e15d52dcb9632314a2 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 731a6331e2..d1ce90d096 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -22,7 +22,7 @@ English | [中文](README.zh.md) ## Use this package -The Host controller serializes mutations whose correctness depends on current registry state and returns stable `WorkspaceError` values for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. +The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index f46c78b7b3..e0b7109275 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -22,7 +22,7 @@ kind: "package-reference" ## 使用本包 -Host 控制器会串行执行正确性取决于当前 registry 状态的变更,并为预期失败返回稳定的 `WorkspaceError` 值。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 +Host 控制器会串行执行正确性取决于当前 registry 状态的变更,并为预期失败抛出带稳定 `workspace/*` 或 `directory-picker/*` 码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回声,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React hook 留给 UI owner。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 8b69e152a0..1977fc9620 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: 75efb18daf3e4a96fea637f36633f216046ebccd -README.zh.md: f3c7099e242e379be5143352d4045208a8209dc6 +README.md: 920780b2193d6165a10967cc49afcb5b7e193105 +README.zh.md: 4ec9ae43eef9a13ffde22e2a3de0e44d3b6cb51d diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 75efb18daf..920780b219 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -63,7 +63,7 @@ A typed API key is judged on its own field: after trimming, it must be non-empty ### Concurrency and credentials -Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`. After settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, so a failed credential stage retries only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent. Once loaded, the page subscribes to forwarded `settings/document-updated`, `credentials/reference-updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so external edits converge without polling. +Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings/conflict`. After settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, so a failed credential stage retries only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent. Once loaded, the page subscribes to forwarded `settings/document-updated`, `credentials/reference-updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so external edits converge without polling. ### Onboarding coordinator diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f3c7099e24..4ec9ae43ee 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -63,7 +63,7 @@ kind: "package-reference" ### 并发与凭据 -每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或外部 `settings.yaml` 编辑的并发写入会以 `settings-conflict` 被拒绝。settings 提交后,卡片会在存储凭据前采纳返回的脱敏用户子树与 revision,因此失败的凭据阶段只重试该阶段。删除只会在 profile 指名本页派生的 `_API_KEY` 目标时移除已配置且可写的凭据,然后 unset 该 profile;两个操作都幂等。加载完成后,页面订阅转发的 `settings/document-updated`、`credentials/reference-updated` 与 `llm/adapters-updated` 属主事件,以及本地 `connection/reset`,因此外部编辑无需轮询即可收敛。 +每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或外部 `settings.yaml` 编辑的并发写入会以 `settings/conflict` 被拒绝。settings 提交后,卡片会在存储凭据前采纳返回的脱敏用户子树与 revision,因此失败的凭据阶段只重试该阶段。删除只会在 profile 指名本页派生的 `_API_KEY` 目标时移除已配置且可写的凭据,然后 unset 该 profile;两个操作都幂等。加载完成后,页面订阅转发的 `settings/document-updated`、`credentials/reference-updated` 与 `llm/adapters-updated` 属主事件,以及本地 `connection/reset`,因此外部编辑无需轮询即可收敛。 ### 引导协调器 diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 9a230d0026..e59417b4c8 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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/README.md -README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 -README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 +README.md: 1dbefefe51086a68d1f2afd36d098a118337f0b9 +README.zh.md: 7009b4a7f156a613a7a1ef4e38b14c11d40fa85a diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 3e4970bff9..1dbefefe51 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Feature plugins use this package to store and edit their preferences without re-implementing transport or schema handling. Mount it once per composition; it injects `connection` and `remote` and owns the single `settings.describe` reader in the browser. +Feature plugins use this package to store and edit their preferences without re-implementing transport or schema handling. Mount it once per composition; it injects the `remote` service with its `settings` namespace and owns the single `settings.describe` reader in the browser. ### Binding a namespace @@ -51,7 +51,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of ### The describe mirror -The plugin injects `connection` and `remote` and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). +The plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). ### Scope derivation diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index a527dfe21a..7009b4a7f1 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -功能插件用本包存储与编辑自己的偏好设置,而无需重新实现传输层或 schema 处理。每个组合挂载一次即可;它注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方。 +功能插件用本包存储与编辑自己的偏好设置,而无需重新实现传输层或 schema 处理。每个组合挂载一次即可;它注入 `remote` 服务及其 `settings` 命名空间,并持有浏览器中唯一的 `settings.describe` 读取方。 ### 绑定命名空间 @@ -51,7 +51,7 @@ kind: "package-reference" ### Describe 镜像 -插件注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 +插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 ### Scope 派生 diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 17eb6ee673..ad1a1787a7 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: bf8c751b77cd5989ee4174b98d7e945c7a20347d -README.zh.md: e3035049a2e2a2bdd6320f448da3b93b05381866 +README.md: 085b819258f235bcbda0d5406c52fc03a12b81d5 +README.zh.md: e2292802b19109cc6e39100fb9c0ff5147a78497 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index bf8c751b77..085b819258 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -46,6 +46,22 @@ await runtime.dispose() A registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) so `.snap` files stay structural, and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead of the auto frame; `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis and is idempotent. +### Scripting Remote answers and failures + +`TestRemote` is the double for the `ctx.remote` face: it registers itself plus one service per scripted namespace so a plugin injecting `remote.` unparks, drives `$on` subscriptions from an explicit test event driver, and exposes `$host` as a plain mutable field a spec assigns to script a homed or non-loopback Host. This package is also where a UI spec takes the `RemoteError` constructor as a value — the `dsh-api-remotes` facade cannot carry it, because a value import from a spec would pull that assembly's unbuilt `/remote` artifact chain. + +Script a failure by the code the Host would answer with, and assert the same way production code discriminates — on `code`, never on the class: + +```text +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' + +remote.goals.create.mockResolvedValue({ + ok: false, + error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }), +}) +expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') +``` + ### When to use it Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is browser-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. @@ -78,7 +94,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs | | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state | | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | -| [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC | +| [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the mounted production packages own theirs) | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index e3035049a2..e2292802b1 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -46,6 +46,22 @@ await runtime.dispose() 注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`),使 `.snap` 文件只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)` 而非自动 frame;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态,且幂等。 +### 脚本化 Remote 应答与失败 + +`TestRemote` 是 `ctx.remote` 面的替身:它把自己连同每个被脚本化的命名空间各注册一个服务,使注入 `remote.` 的插件得以解除挂起;`$on` 订阅由显式的测试事件驱动器推动;`$host` 是普通可变字段,套件直接赋值即可脚本化带 home 或非 loopback 的 Host。UI 套件也在本包取用 `RemoteError` 构造器这个值——`dsh-api-remotes` facade 承载不了它,因为从套件发起的值 import 会拉起该装配尚未构建的 `/remote` 产物链。 + +按 Host 会答的码来脚本化失败,并以生产代码同样的方式断言——判 `code`,绝不判类: + +```text +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' + +remote.goals.create.mockResolvedValue({ + ok: false, + error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }), +}) +expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') +``` + ### 何时使用 当功能套件要在真实运行时下检验 slot、store、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是浏览器侧测试基础设施:永远不触及模型请求,feature 包仅以 `devDependencies` 依赖之。 @@ -78,7 +94,7 @@ await runtime.dispose() | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 | | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 | | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | -| [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身 | +| [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;所挂载的生产包拥有各自的不变式) | diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 437641bc61..25d7006dab 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: 34e685b841e3bf98d1b23306ed90fd3ca8381f79 -README.zh.md: d85cda322f9a3117a4b7bbe4405140b75d5915b8 +README.md: 9c8aa6bd3e480f76cbeab97f969ef8facf6769e1 +README.zh.md: 8cd267213647db54010ddd4f6e63cfc0ec71b608 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 34e685b841..9c8aa6bd3e 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -46,7 +46,22 @@ Generation turns the method into a wire endpoint under the service's namespace; ### Associating Host objects and Contexts with wire identities -Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. Host and Client Context adapters both map `Context` to a wire identity and that identity back to `Context`; the Host adapter also owns the stable wire declaration. Host composition may override its synchronous or asynchronous resolver. A policy rejection can throw `TypertLookupFailure` to carry an adapter-owned failure value to the caller. +Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. Host and Client Context adapters both map `Context` to a wire identity and that identity back to `Context`; the Host adapter also owns the stable wire declaration. Host composition may override its synchronous or asynchronous resolver. A resolver that refuses on policy grounds throws `RemoteError` with its own code, which reaches the caller unchanged. + +### Reporting and reading a Remote failure + +One class carries every Remote failure: `RemoteError`, holding a stable `/` code and the details typed for that code. This package declares the universal carrier codes (`gateway/bad-request`, `gateway/cancelled`, `gateway/internal`) and owns `RemoteErrorDetailsMap`, the merge-extensible table every other package extends beside its own throwing code: + +```text +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'goal/not-found': { readonly goalId: string } + } +} +throw new RemoteError('goal/not-found', `goal "${id}" does not exist`, { goalId: id }) +``` + +An owner throws at the failure point; no package writes an error-class family or an exit-mapping function. A caller discriminates by `code` — never by `instanceof` — and a `code` branch narrows `details` with no cast, because `RemoteFailure` is the code-discriminated union of `RemoteError` instances. Infrastructure that must recognize a failure carried across a module or realm copy of the class calls `remoteErrorOf(value)`, which reads a structural marker instead of the prototype chain. ### Receiving forwarded Host events on the Client @@ -82,8 +97,9 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation, `TypertLookupFailure` | -| [`src/types.ts`](src/types.ts) | Protocol maps, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | +| [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | +| [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | +| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index d85cda322f..8cd2672136 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -46,7 +46,22 @@ export class GoalService extends TypertRemoteService { ### 把 Host 对象与 Context 关联到 wire identity -复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host 与 Client Context adapter 都把 `Context` 映射为 wire identity,也把该 identity 映射回 `Context`;Host adapter 还拥有稳定 wire 声明。Host 组合可以覆盖其同步或异步 resolver。策略拒绝可以抛出 `TypertLookupFailure`,把适配器拥有的失败值带给调用方。 +复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host 与 Client Context adapter 都把 `Context` 映射为 wire identity,也把该 identity 映射回 `Context`;Host adapter 还拥有稳定 wire 声明。Host 组合可以覆盖其同步或异步 resolver。因策略而拒绝的 resolver 抛出带自有码的 `RemoteError`,该码原样到达调用方。 + +### 报告与读取 Remote 失败 + +所有 Remote 失败都由一个类承载:`RemoteError`,携带稳定的 `/` 码,以及按该码定型的 details。本包声明通用载体码(`gateway/bad-request`、`gateway/cancelled`、`gateway/internal`),并拥有 `RemoteErrorDetailsMap`——可合并扩展的码表,其他每个包都在自己的抛出点旁扩展它: + +```text +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'goal/not-found': { readonly goalId: string } + } +} +throw new RemoteError('goal/not-found', `goal "${id}" does not exist`, { goalId: id }) +``` + +拥有方在失败点直接抛出;没有任何包再写错误类家族或出口映射函数。调用方按 `code` 判别——绝不用 `instanceof`——且 `code` 分支无需 cast 即收窄 `details`,因为 `RemoteFailure` 就是 `RemoteError` 实例按码判别的 union。需要识别跨模块或跨 realm 类副本传来的失败时,基础设施调用 `remoteErrorOf(value)`,它读结构标记而不是原型链。 ### 在 Client 侧接收转发的 Host 事件 @@ -82,8 +97,9 @@ Host 装配以转发给消费端的 Cordis 事件扩展 `TypertRemoteEventSelect | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验、`TypertLookupFailure` | -| [`src/types.ts`](src/types.ts) | 协议映射、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | +| [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | +| [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | +| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index dfba9d3271..9e92a22e37 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/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/util/README.md -README.md: d1f48f165952a283b231e416b3391585cfee81a8 -README.zh.md: 69eb0cf9bb5845a4aeeb3880ef70fac049efbade +README.md: dc7e2912ecc77ffb9710a45bf4c127b80b825ab6 +README.zh.md: 46878a4d0001e7daf42078074c2e82a5bcaf461c diff --git a/packages/util/README.md b/packages/util/README.md index d1f48f1659..dc7e2912ec 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the zero-dependency utility family: atomic file writes, branded ids, harness home paths, the launch environment, native commands, output retention, and timeouts." +description: "Package map for the zero-dependency utility family: atomic file writes, branded ids, harness home paths, the launch environment, native commands, output retention, time zones, and timeouts." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, UUIDs, Harness-home paths, launch environments, native commands, output retention, and timeout handling. Every package here is a library: it registers no service or event, and the consuming capability retains the business semantics. +The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, UUIDs, Harness-home paths, launch environments, native commands, output retention, time-zone canonicalization, and timeout handling. Every package here is a library: it registers no service or event, and the consuming capability retains the business semantics. ## Table of Contents @@ -34,6 +34,7 @@ Each package provides one primitive; open a package page for how to use it. | [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string | | [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | | [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata | +| [`time/`](time/README.md) | Validates and canonicalizes a caller-reported IANA time zone | | [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification | ----- diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 69eb0cf9bb..46878a4d00 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -1,5 +1,5 @@ --- -description: "零依赖工具家族的包映射:原子文件写入、品牌化 id、harness 主目录路径、启动环境、原生命令、输出保留与超时。" +description: "零依赖工具家族的包映射:原子文件写入、品牌化 id、harness 主目录路径、启动环境、原生命令、输出保留、时区与超时。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、UUID、Harness home 路径、启动环境、原生命令、输出保留和超时处理。这里的每个包都是库:它不注册服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、UUID、Harness home 路径、启动环境、原生命令、输出保留、时区规范化和超时处理。这里的每个包都是库:它不注册服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -34,6 +34,7 @@ kind: "package-group" | [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 | | [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 | | [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 | +| [`time/`](time/README.zh.md) | 校验并规范化调用方所报的 IANA 时区 | | [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时/取消分类 | ----- diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml new file mode 100644 index 0000000000..d87f011269 --- /dev/null +++ b/packages/util/time/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/util/time/README.md +README.md: 40f6b4d9ef50f0f35eb9b143ed31cccce352647c +README.zh.md: f6648415dc1ab89382f92e9f0e6ab9465e7fc2b1 diff --git a/packages/util/time/README.md b/packages/util/time/README.md new file mode 100644 index 0000000000..40f6b4d9ef --- /dev/null +++ b/packages/util/time/README.md @@ -0,0 +1,68 @@ +--- +description: "IANA time-zone validation and canonicalization for maintainers accepting a caller-reported zone at a wire boundary." +kind: "package-library" +--- + +# dsh-util-time + +English | [中文](README.zh.md) + +## Summary + +Zero-dependency zone vocabulary for the wire boundaries that accept a caller's time zone. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling of it, so an alias never reaches a durable record: a zone identity is stored on messages and re-derived later by another process, where an alias would not compare equal. The library validates and canonicalizes only — it formats no time and owns no failure vocabulary, because each boundary throws its own domain code. + +## Table of Contents + +- [Use this package](#use-this-package) +- [API](#api) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state. + +Call it at the boundary that receives the zone, before the value reaches anything durable. An unusable name answers `undefined`, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. + +----- + + +## API + +```ts +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +``` + +| Export | Role | +|---|---| +| `canonicalClientTimeZone(value)` | Canonical `UTC` or IANA `Area/Location` name for an accepted zone, `undefined` for a blank, padded, abbreviated, single-segment, or platform-unsupported one. | + + +## Model Experience + +Indirectly, through the consumer that records a canonical zone on a durable message, from which `dsh-time-context` renders the turn's model-visible zone instruction and timestamp. + +#### KV Cache effect + +None of its own. The consumer that injects a zone-derived line into a request owns that request's cache behavior. + +## Known Limitations and Deferred Work + + + +- **Alias resolution follows the runtime's ICU data** — which name an alias group canonicalizes to is the platform's answer, so two processes on different Node builds can disagree about it. +- **Validation only** — no formatting, offset arithmetic, DST reasoning, or instant conversion; consumers needing those use `Intl` directly. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md new file mode 100644 index 0000000000..f6648415dc --- /dev/null +++ b/packages/util/time/README.zh.md @@ -0,0 +1,68 @@ +--- +description: "面向在协议边界接收调用方所报时区的维护者,说明 IANA 时区校验与规范化。" +kind: "package-library" +--- + +# dsh-util-time + +[English](README.md) | 中文 + +## 概述 + +零依赖的时区词汇,供接收调用方时区的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答该名称在当前平台上的规范拼写,因此别名不会进入持久记录:时区标识会存在消息上、并由另一个进程稍后重新推导,别名在那里比不相等。本库只做校验与规范化——不格式化任何时间,也不持有失败词汇,因为每个边界抛自己的域码。 + +## 目录 + +- [使用本包](#use-this-package) +- [API](#api) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 + +在接收时区的那个边界上调用它,让值在进入任何持久物之前先过一遍。不可用的名称回答 `undefined`,由调用方抛出自己的拒绝——Session prompt 用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。 + +----- + + +## API + +```ts +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +``` + +| 导出 | 职责 | +|---|---| +| `canonicalClientTimeZone(value)` | 对接受的时区回答规范的 `UTC` 或 IANA `Area/Location` 名称;空串、带空白、缩写、单段或平台不支持的名称回答 `undefined`。 | + + +## Model Experience + +间接影响,取决于把规范时区记到持久消息上的那个消费方——`dsh-time-context` 据此渲染该轮模型可见的时区指令与时间戳。 + +#### KV Cache effect + +自身没有。把时区派生文本注入请求的那个消费方,对该请求的缓存行为负责。 + +## Known Limitations and Deferred Work + + + +- **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 +- **只做校验**——不格式化、不做偏移运算、不推导 DST、不做时刻换算;需要这些的消费方直接用 `Intl`。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 1f7f25cb2a..6c93dc54fd 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -5,6 +5,6 @@ "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, - "packages/AGENTS.md": 675, + "packages/AGENTS.md": 714, "packages/README.md": 994 } diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index f7c5fd8c51..989540965e 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -82,6 +82,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/launch-environment': 'Zero-dependency environment resolver.', 'packages/util/native-command': 'Host-side subprocess runner utility.', 'packages/util/output-retention': 'Zero-dependency retention utility.', + 'packages/util/time': 'Zero-dependency time-zone canonicalization utility.', 'packages/util/timeout': 'Zero-dependency timeout utility.', 'packages/util/workspace-path': 'Zero-dependency Workspace path formatter.', } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c8f7e84d42..37a488c021 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to PTC mode in dsh-tools.' }, 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, + 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, From 39a5a1d7e4f00c8467116c2898b00a58cec88c32 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:52:13 +0800 Subject: [PATCH 057/429] chore(docs): regenerate catalogs, graphs, and the message-feedback golden - gen-cordis-catalog / gen-cordis-inspect-catalog / gen-client-catalog / gen-config-catalog / gen-doc-graphs / gen-module-graph outputs pick up the converged Remote vocabulary (gateway/* codes, RemoteError JSDoc) and the dsh-util-time package; the zh sides of the three English-generated pages follow the same line-number shifts. - the message-feedback protocol golden records the accepted wire change: a boundary-validation failure now reports gateway/input-invalid with structured details instead of a bare internal code. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 7 +- docs/config-catalog.zh.md | 7 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +-- docs/event-producer-consumer.zh.md | 12 +-- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 28 ++++--- docs/module-graph.zh.md | 28 ++++--- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 16 ++-- docs/subsystems/core.zh.md | 16 ++-- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 9 ++- docs/subsystems/credentials.zh.md | 9 ++- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 14 ++-- docs/subsystems/settings.zh.md | 14 ++-- docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 2 +- docs/subsystems/skills.zh.md | 2 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 20 ++--- docs/subsystems/subagent.zh.md | 20 ++--- .../src/client/api-catalog.ts | 14 ++-- .../src/client/slot-catalog.ts | 6 +- .../extensions/tool-cordis/src/api-catalog.ts | 74 +++++++++++-------- .../protocol.expected.json | 7 +- 34 files changed, 198 insertions(+), 167 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e243871af1..ad34214f63 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: 3c3699228bf5d4c0f49f8134d7c342f452de92c9 -config-catalog.zh.md: 0d96acf23a302d7365e6ebdb4aa3fe7cba4af400 +config-catalog.md: a48163a2ea9b04c99e2322620e207cc0a5aa3b88 +config-catalog.zh.md: bb662eecf45d72eb605a93663e9177cab10fcc34 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c3699228b..a48163a2ea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -157,7 +157,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -Source: [`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts) +Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) @@ -290,7 +290,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:118`](../packages/api/gateway/src/index.ts) @@ -322,7 +322,7 @@ export interface Config { } ``` -Source: [`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) +Source: [`packages/api/settings-controller/src/index.ts:37`](../packages/api/settings-controller/src/index.ts) @@ -3549,5 +3549,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@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-util-crypto` ([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) +- `@deepseek-ai/dsh-util-time` ([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-workspace-path` ([`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/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 0d96acf23a..bb662eecf4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -159,7 +159,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -来源:[`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts) +来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) @@ -292,7 +292,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:118`](../packages/api/gateway/src/index.ts) @@ -324,7 +324,7 @@ export interface Config { } ``` -来源:[`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) +来源:[`packages/api/settings-controller/src/index.ts:37`](../packages/api/settings-controller/src/index.ts) @@ -3550,5 +3550,6 @@ export interface Config { - `@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-util-crypto`([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) +- `@deepseek-ai/dsh-util-time`([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-workspace-path`([`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/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/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index cf5c3ea52e..9a7f9fcd3c 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: 24ed4b92daae6d06a862618e398605fce7063f4f -event-producer-consumer.zh.md: af440aa4155b65a211b7fdc9bce232a264bbe3a7 +event-producer-consumer.md: 53ed72ca93702ad06d63d3fb3b80ab6aba05baaa +event-producer-consumer.zh.md: 8f4b12a8e7dbea96b735320997a5a0a2a4a8a545 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24ed4b92da..53ed72ca93 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:521`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:501`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:507`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:514`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index af440aa415..8f4b12a8e7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:521`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:501`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:507`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:514`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 73269aadfb..04e07d0c19 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: 90c0f2061d27fa6410dd867edb94089440fab006 -module-graph.zh.md: fb3d7c7f6a22b9ff2f6b41ed9745c050aa483759 +module-graph.md: 5c8d6a4f4c9eff9f2ddee548ecd9003e7a5eda8b +module-graph.zh.md: e507a11e6e3e1c220c7947a76cda16645e79512f diff --git a/docs/module-graph.md b/docs/module-graph.md index 90c0f2061d..5c8d6a4f4c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -16,6 +16,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] + pkg_util_time["util-time"] pkg_util_workspace_path["util-workspace-path"] end subgraph group_llm["packages/llm"] @@ -365,6 +366,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_util_time --> pkg_invariants pkg_util_workspace_path --> pkg_invariants pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants @@ -556,6 +558,7 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_workspace --> pkg_typert_protocol pkg_llm_deepseek --> pkg_anonymous_user_id pkg_llm_deepseek --> pkg_atomic_write pkg_llm_deepseek --> pkg_attachment @@ -1087,6 +1090,7 @@ flowchart TD pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_attachment pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1103,6 +1107,7 @@ flowchart TD pkg_subagent --> pkg_tools pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval + pkg_subagent --> pkg_util_time pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1253,6 +1258,7 @@ flowchart TD pkg_api_session_controller --> pkg_subagent pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_time pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway @@ -1350,7 +1356,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess pkg_client_ui_settings --> pkg_api_remotes - pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes @@ -1370,7 +1375,6 @@ flowchart TD 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_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1389,7 +1393,6 @@ flowchart TD 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 @@ -1503,7 +1506,6 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule 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 @@ -1711,6 +1713,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent + pkg_client_test_runtime --> pkg_typert_protocol pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_api_session_controller pkg_client_ui_skill --> pkg_client_connection @@ -1743,6 +1746,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`util-time`](../packages/util/time) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1821,7 +1825,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`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) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`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), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`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) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | @@ -1909,7 +1913,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | @@ -1927,7 +1931,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1941,14 +1945,14 @@ 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), [`attachment`](../packages/attachment/attachment), [`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) | -| [`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-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`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-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-settings-plugins`](../packages/client/ui-settings-plugins) | `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-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-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) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`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), [`util-workspace-path`](../packages/util/workspace-path), [`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-remotes`](../packages/api/remotes), [`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), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | @@ -1961,7 +1965,7 @@ flowchart TD | [`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-schedule`](../packages/client/ui-schedule) | `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), [`schedule`](../packages/schedule/schedule) | -| [`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-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1979,6 +1983,6 @@ flowchart TD | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`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), [`util-workspace-path`](../packages/util/workspace-path) | | [`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), [`subagent`](../packages/subagent/subagent) | +| [`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), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`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 fb3d7c7f6a..e507a11e6e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -18,6 +18,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] + pkg_util_time["util-time"] pkg_util_workspace_path["util-workspace-path"] end subgraph group_llm["packages/llm"] @@ -367,6 +368,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_util_time --> pkg_invariants pkg_util_workspace_path --> pkg_invariants pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants @@ -558,6 +560,7 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_workspace --> pkg_typert_protocol pkg_llm_deepseek --> pkg_anonymous_user_id pkg_llm_deepseek --> pkg_atomic_write pkg_llm_deepseek --> pkg_attachment @@ -1089,6 +1092,7 @@ flowchart TD pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_attachment pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1105,6 +1109,7 @@ flowchart TD pkg_subagent --> pkg_tools pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval + pkg_subagent --> pkg_util_time pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1255,6 +1260,7 @@ flowchart TD pkg_api_session_controller --> pkg_subagent pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_time pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway @@ -1352,7 +1358,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess pkg_client_ui_settings --> pkg_api_remotes - pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes @@ -1372,7 +1377,6 @@ flowchart TD 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_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1391,7 +1395,6 @@ flowchart TD 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 @@ -1505,7 +1508,6 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule 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 @@ -1713,6 +1715,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent + pkg_client_test_runtime --> pkg_typert_protocol pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_api_session_controller pkg_client_ui_skill --> pkg_client_connection @@ -1745,6 +1748,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`util-time`](../packages/util/time) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1823,7 +1827,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`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) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`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), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`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) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | @@ -1911,7 +1915,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | @@ -1929,7 +1933,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1943,14 +1947,14 @@ 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), [`attachment`](../packages/attachment/attachment), [`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) | -| [`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-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`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-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-settings-plugins`](../packages/client/ui-settings-plugins) | `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-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-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) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`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), [`util-workspace-path`](../packages/util/workspace-path), [`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-remotes`](../packages/api/remotes), [`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), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | @@ -1963,7 +1967,7 @@ flowchart TD | [`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-schedule`](../packages/client/ui-schedule) | `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), [`schedule`](../packages/schedule/schedule) | -| [`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-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1981,6 +1985,6 @@ flowchart TD | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`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), [`util-workspace-path`](../packages/util/workspace-path) | | [`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), [`subagent`](../packages/subagent/subagent) | +| [`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), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`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/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 06bfe0a937..90dc78dba3 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: dbe12dfc9bfdafe0f59d7e52eefc5695b1c8d063 -core.zh.md: 50fd2e4e76ee72653c02c2ffeca06042191aa9c4 +core.md: 2f81b9d3456edae88a4f777b376bc5394c32a14e +core.zh.md: e4afda0c3c57acab49dbcfe3f8ce8cb3f08c3d23 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index dbe12dfc9b..2f81b9d345 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -488,8 +488,8 @@ async read(id: string): Promise * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. * @returns the composition beside its trust and published metadata. - * @throws {TypertRemoteFailure} `bad-request` for an empty id, or - * `agent-preset-not-found` when no configured root supplies it. + * @throws {RemoteError} `gateway/bad-request` for an empty id, or + * `agent-preset/not-found` when no configured root supplies it. */ @Remote('read') async readDocument(agentPreset: string): Promise @@ -516,8 +516,8 @@ async copy(from: string, id: string, name?: string): Promise * @param id - the new preset id. * @param name - the copy's optional display name. * @returns once the copy is stored. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when the copy is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when the copy is refused. */ @Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise @@ -533,8 +533,8 @@ async remove(id: string): Promise * Delete one preset through the Remote API. * @param id - the preset id. * @returns once the preset is deleted. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when deletion is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when deletion is refused. */ @Remote('deletePreset') async remoteExportDelete(id: string): Promise @@ -585,8 +585,8 @@ async recompose(agentCtx: Context, id: string): Promise * @param agent - the session's live agent, resolved from the wire identity. * @param agentPreset - the preset to compose the agent from instead. * @returns the preset id that was recorded. - * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, - * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, + * `agent-preset/not-found`, or `agent-preset/invalid` when refused. */ @Remote('select') async select(agent: Agent, agentPreset: string): Promise diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 50fd2e4e76..e4afda0c3c 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -498,8 +498,8 @@ async read(id: string): Promise * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. * @returns the composition beside its trust and published metadata. - * @throws {TypertRemoteFailure} `bad-request` for an empty id, or - * `agent-preset-not-found` when no configured root supplies it. + * @throws {RemoteError} `gateway/bad-request` for an empty id, or + * `agent-preset/not-found` when no configured root supplies it. */ @Remote('read') async readDocument(agentPreset: string): Promise @@ -526,8 +526,8 @@ async copy(from: string, id: string, name?: string): Promise * @param id - the new preset id. * @param name - the copy's optional display name. * @returns once the copy is stored. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when the copy is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when the copy is refused. */ @Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise @@ -543,8 +543,8 @@ async remove(id: string): Promise * Delete one preset through the Remote API. * @param id - the preset id. * @returns once the preset is deleted. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when deletion is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when deletion is refused. */ @Remote('deletePreset') async remoteExportDelete(id: string): Promise @@ -595,8 +595,8 @@ async recompose(agentCtx: Context, id: string): Promise * @param agent - the session's live agent, resolved from the wire identity. * @param agentPreset - the preset to compose the agent from instead. * @returns the preset id that was recorded. - * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, - * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, + * `agent-preset/not-found`, or `agent-preset/invalid` when refused. */ @Remote('select') async select(agent: Agent, agentPreset: string): Promise diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index b9f925005d..626f298b8b 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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/credentials.md -credentials.md: 60cd388fc18fe239b9ee5207d77d3b5561b7a3b3 -credentials.zh.md: 5ca47fac6c36c14eb1ee0aa8a6cb6a8b5e3eb9c4 +credentials.md: 5ae53a19140f423e1c52334c40f25fca7d3ed754 +credentials.zh.md: b6d5d8eb3960edd68a96a32a41c591e61f2c8b5e diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 60cd388fc1..5ae53a1914 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -227,9 +227,10 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * Describe several references for one configuration surface. Batched because * a settings page describes every reference its rows name at once, and one * round trip keeps those rows from settling separately. - * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar + * rejects the whole call as `gateway/bad-request`. * @returns one view per requested name, keyed by that name. - * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + * @throws RemoteError when the request is invalid or no credential provider is mounted. */ @Remote async describe(refs: string[]): Promise> @@ -238,14 +239,14 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async set(ref: string, value: string): Promise /** * Remove one reference from a configuration surface. * @param ref - reference name to remove. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async unset(ref: string): Promise ``` diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 5ca47fac6c..b6d5d8eb39 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -227,9 +227,10 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * Describe several references for one configuration surface. Batched because * a settings page describes every reference its rows name at once, and one * round trip keeps those rows from settling separately. - * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar + * rejects the whole call as `gateway/bad-request`. * @returns one view per requested name, keyed by that name. - * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + * @throws RemoteError when the request is invalid or no credential provider is mounted. */ @Remote async describe(refs: string[]): Promise> @@ -238,14 +239,14 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async set(ref: string, value: string): Promise /** * Remove one reference from a configuration surface. * @param ref - reference name to remove. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async unset(ref: string): Promise ``` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 19974d41f8..ea90fbb43d 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: e37356d91e75334987b6fc707744f2376df1b2fc -llm-streaming.zh.md: 05f24bed6beff9508db88e60af7b259eda33f474 +llm-streaming.md: 6867ae292d77474bcedc1466ae0ce6b1fc1c92d3 +llm-streaming.zh.md: b75e24f2e9010b4fb08c035f14bc4e91dc3971ef diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index e37356d91e..6867ae292d 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -929,7 +929,7 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, sig * @param request - endpoint, protocol, and one-shot credential to use. * @param signal - caller cancellation supplied by the Remote carrier. * @returns advertised models in endpoint order. - * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + * @throws RemoteError with `llm/model-discovery-rejected` when discovery refuses or fails. */ @Remote('discoverModels') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 05f24bed6b..b75e24f2e9 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -935,7 +935,7 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, sig * @param request - endpoint, protocol, and one-shot credential to use. * @param signal - caller cancellation supplied by the Remote carrier. * @returns advertised models in endpoint order. - * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + * @throws RemoteError with `llm/model-discovery-rejected` when discovery refuses or fails. */ @Remote('discoverModels') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f459ae1fd9..7624766c51 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 -session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 +session.md: d06932171b6fcf0df3470d0236a0e8b0f5aa6c21 +session.zh.md: fc8c4ec53b875614a082777eaa109f1a2514268e diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f3c246f7a7..d06932171b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -660,7 +660,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @param request - path after best-effort Session workspace resolution. * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + * @throws RemoteError when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bc706a11e9..fc8c4ec53b 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -664,7 +664,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @param request - path after best-effort Session workspace resolution. * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + * @throws RemoteError when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 2efb877591..f03bec4ed3 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.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/settings.md -settings.md: 3215de191b4ef8fecb373d280c8bc8e4a89bbc7e -settings.zh.md: 0c28ef381ee6ea8e59da64717575fbf80c8a2e5f +settings.md: 1a83e76d908ff19ca0f3abb3b21e10cae0d0685f +settings.zh.md: af204ccfbda419996686ba691ee57e884c18e631 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 3215de191b..1a83e76d90 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -262,14 +262,14 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ### `ctx.settingsController` — `SettingsController` -Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role('secret')` field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service's message. +Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role('secret')` field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as `settings/conflict` or `settings/rejected` with the service's message. ```ts cordis-catalog /** * Describe every registered namespace for a configuration page: redacted * layered values plus the serialized schema the page renders its form from. * @returns provider writability, local-document presence, and one view per namespace. - * @throws TypertRemoteFailure when no settings provider is mounted. + * @throws RemoteError when no settings provider is mounted. */ @Remote describe(): SettingsDescribeValue @@ -285,7 +285,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise @@ -295,7 +295,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise @@ -307,7 +307,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise @@ -315,7 +315,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * Materialize the provider-owned settings document and open it in a native text editor. * @param signal - caller lifetime; abort terminates preparation or the native command. * @returns confirmation after the native opener accepts the document. - * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + * @throws RemoteError when no document exists, preparation fails, or opening fails. */ @Remote async openSettingsDocument(signal: AbortSignal): Promise @@ -324,7 +324,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param agentPreset - preset id resolved against Host-owned roots. * @param signal - caller lifetime; abort terminates the native command. * @returns an opened confirmation or the resolved directory for text display. - * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. */ @Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 0c28ef381e..af204ccfbd 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -262,14 +262,14 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ### `ctx.settingsController` — `SettingsController` -Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role('secret')` field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service's message. +Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role('secret')` field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as `settings/conflict` or `settings/rejected` with the service's message. ```ts cordis-catalog /** * Describe every registered namespace for a configuration page: redacted * layered values plus the serialized schema the page renders its form from. * @returns provider writability, local-document presence, and one view per namespace. - * @throws TypertRemoteFailure when no settings provider is mounted. + * @throws RemoteError when no settings provider is mounted. */ @Remote describe(): SettingsDescribeValue @@ -285,7 +285,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise @@ -295,7 +295,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise @@ -307,7 +307,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise @@ -315,7 +315,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * Materialize the provider-owned settings document and open it in a native text editor. * @param signal - caller lifetime; abort terminates preparation or the native command. * @returns confirmation after the native opener accepts the document. - * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + * @throws RemoteError when no document exists, preparation fails, or opening fails. */ @Remote async openSettingsDocument(signal: AbortSignal): Promise @@ -324,7 +324,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @param agentPreset - preset id resolved against Host-owned roots. * @param signal - caller lifetime; abort terminates the native command. * @returns an opened confirmation or the resolved directory for text display. - * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. */ @Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index 7bf2ead73e..0c7d15ddef 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.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/skills.md -skills.md: 18eb4d3289cec5c7807feab4229096b5799d4168 -skills.zh.md: aa167373cb9736cf24d7bf20dc965e68568826be +skills.md: 8224b91290c110d52d739cd85b3fc7d641f9a7ea +skills.zh.md: 018bd85d74ba40717c741d6600f991f3c6d36d4c diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 18eb4d3289..8224b91290 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -258,7 +258,7 @@ Host service backing `ctx.remote.skills` without activating a cold Agent. * @param request - Session identity whose cwd and preset select the catalog view. * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. * @returns user-invocable skill metadata without loading skill bodies. - * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + * @throws RemoteError when the Session cannot be inspected or no registry can serve it. */ @Remote async list(request: SkillListRequest, signal: AbortSignal): Promise ``` diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index aa167373cb..018bd85d74 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -258,7 +258,7 @@ Host service backing `ctx.remote.skills` without activating a cold Agent. * @param request - Session identity whose cwd and preset select the catalog view. * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. * @returns user-invocable skill metadata without loading skill bodies. - * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + * @throws RemoteError when the Session cannot be inspected or no registry can serve it. */ @Remote async list(request: SkillListRequest, signal: AbortSignal): Promise ``` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 325ef7599a..363ee89e12 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: dfadbbdee28607c929e629a20c18af6b55f4f51e -subagent.zh.md: d4ffe30d69aff404733a6e8acbc2d1a19e2276d3 +subagent.md: cedbc8544cb86db9feda4b0a2bcf5a65cf99bfe9 +subagent.zh.md: 4eabe673b0e750e0b3ed5480299b93a377886b13 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index dfadbbdee2..cedbc8544c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -657,9 +657,9 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -672,10 +672,10 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -689,9 +689,9 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -676,10 +676,10 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -693,9 +693,9 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise>', + signature: 'search( query: string, signal: AbortSignal, ): Promise>', description: 'Search the Host\'s visible message-content index. Results stay request-local; the list snapshot remains the metadata authority.', parameters: [{ name: 'query', description: 'non-blank literal phrase.' }, { name: 'signal', description: 'cancellation for a superseded search.' }], returns: 'bounded results, or a business/transport error.', @@ -471,7 +471,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ClientRemote', - declaration: 'export interface ClientRemote extends TypertClientRemote {\n $stream(options: RemoteStreamOptions): RemoteStream;\n}', + declaration: 'export interface ClientRemote extends TypertClientRemote {\n $stream(options: RemoteStreamOptions): RemoteStream;\n readonly $host: RemoteHostFacts;\n}', }, { name: 'CommonKeyOf', @@ -551,7 +551,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', @@ -627,7 +627,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptError', - declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: ClientFailure;\n}', + declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: RemoteFailure;\n}', }, { name: 'PropsHooks', @@ -653,6 +653,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PropsStore', declaration: 'export type PropsStore = H extends StoreHandle ? {\n useStore: SnapshotSelectorHook;\n actions: BakedActions;\n} : object;', }, + { + name: 'RemoteHostFacts', + declaration: 'export interface RemoteHostFacts {\n readonly home: string | undefined;\n readonly isLoopback: boolean;\n}', + }, { name: 'RemoteStream', declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', @@ -727,7 +731,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSnapshot', - declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', + declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: RemoteFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', }, { name: 'SessionStandardProps', 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 b01882d442..4be9be57f0 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -650,7 +650,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.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', }, { key: 'conversation.input.attachments', @@ -2113,7 +2113,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.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', }, { key: 'tool.call.toolview', @@ -2170,7 +2170,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:25', }, { key: 'tool.view.cordis', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 28e8d3dc06..943f60b85d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -186,7 +186,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'One preset\'s composition text with the roster row it belongs to.', parameters: [{ name: 'agentPreset', description: 'the preset id.' }], returns: 'the composition beside its trust and published metadata.', - throws: ['{TypertRemoteFailure} `bad-request` for an empty id, or `agent-preset-not-found` when no configured root supplies it.'], + throws: ['{RemoteError} `gateway/bad-request` for an empty id, or `agent-preset/not-found` when no configured root supplies it.'], }, { signature: 'async copy(from: string, id: string, name?: string): Promise', @@ -199,7 +199,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Copy one preset through the Remote API.', parameters: [{ name: 'from', description: 'the source preset id.' }, { name: 'id', description: 'the new preset id.' }, { name: 'name', description: 'the copy\'s optional display name.' }], returns: 'once the copy is stored.', - throws: ['{TypertRemoteFailure} with the corresponding stable preset code and details when the copy is refused.'], + throws: ['{RemoteError} with the corresponding stable preset code and details when the copy is refused.'], }, { signature: 'async remove(id: string): Promise', @@ -212,7 +212,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Delete one preset through the Remote API.', parameters: [{ name: 'id', description: 'the preset id.' }], returns: 'once the preset is deleted.', - throws: ['{TypertRemoteFailure} with the corresponding stable preset code and details when deletion is refused.'], + throws: ['{RemoteError} with the corresponding stable preset code and details when deletion is refused.'], }, { signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', @@ -232,7 +232,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Compose a blank session\'s agent from a different preset and record it.', parameters: [{ name: 'agent', description: 'the session\'s live agent, resolved from the wire identity.' }, { name: 'agentPreset', description: 'the preset to compose the agent from instead.' }], returns: 'the preset id that was recorded.', - throws: ['{TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, `agent-preset-not-found`, or `agent-preset-invalid` when refused.'], + throws: ['{RemoteError} with `gateway/bad-request`, `agent-preset/locked`, `agent-preset/not-found`, or `agent-preset/invalid` when refused.'], }, { signature: 'async standingKeyFor(id?: string): Promise', @@ -732,21 +732,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: '@Remote async describe(refs: string[]): Promise>', description: 'Describe several references for one configuration surface. Batched because a settings page describes every reference its rows name at once, and one round trip keeps those rows from settling separately.', - parameters: [{ name: 'refs', description: 'reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.' }], + parameters: [{ name: 'refs', description: 'reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `gateway/bad-request`.' }], returns: 'one view per requested name, keyed by that name.', - throws: ['TypertRemoteFailure when the request is invalid or no credential provider is mounted.'], + throws: ['RemoteError when the request is invalid or no credential provider is mounted.'], }, { signature: '@Remote async set(ref: string, value: string): Promise', description: 'Store one value from a configuration surface. The value crosses the wire in this direction only: no read path returns it.', parameters: [{ name: 'ref', description: 'reference name to store under.' }, { name: 'value', description: 'the non-empty secret value.' }], - throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { signature: '@Remote async unset(ref: string): Promise', description: 'Remove one reference from a configuration surface.', parameters: [{ name: 'ref', description: 'reference name to remove.' }], - throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, ], }, @@ -1134,7 +1134,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Remote adapter for one draft provider interrogation.', parameters: [{ name: 'settingsNs', description: 'namespace whose registered discovery serves this draft.' }, { name: 'request', description: 'endpoint, protocol, and one-shot credential to use.' }, { name: 'signal', description: 'caller cancellation supplied by the Remote carrier.' }], returns: 'advertised models in endpoint order.', - throws: ['TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails.'], + throws: ['RemoteError with `llm/model-discovery-rejected` when discovery refuses or fails.'], }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', @@ -1381,7 +1381,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Open one path prepared by a Session-aware caller on the Host desktop.', parameters: [{ name: 'request', description: 'path after best-effort Session workspace resolution.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }], returns: 'confirmation after the native opener accepts the path.', - throws: ['TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.'], + throws: ['RemoteError when the request is invalid, cancelled, or the opener fails.'], }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', @@ -1833,7 +1833,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'List the user-invocable skills visible to one Session composition.', parameters: [{ name: 'request', description: 'Session identity whose cwd and preset select the catalog view.' }, { name: 'signal', description: 'caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics.' }], returns: 'user-invocable skill metadata without loading skill bodies.', - throws: ['TypertRemoteFailure when the Session cannot be inspected or no registry can serve it.'], + throws: ['RemoteError when the Session cannot be inspected or no registry can serve it.'], }, ], }, @@ -1951,14 +1951,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'settingsController', summary: 'Host service backing the generated `ctx.remote.settings` namespace.', - description: 'Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role(\'secret\')` field cannot ride a response. Writes expose the settings service\'s merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service\'s message.', + description: 'Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role(\'secret\')` field cannot ride a response. Writes expose the settings service\'s merge, replacement, and path-addressed operations, and classify every provider refusal as `settings/conflict` or `settings/rejected` with the service\'s message.', methods: [ { signature: '@Remote describe(): SettingsDescribeValue', description: 'Describe every registered namespace for a configuration page: redacted layered values plus the serialized schema the page renders its form from.', parameters: [], returns: 'provider writability, local-document presence, and one view per namespace.', - throws: ['TypertRemoteFailure when no settings provider is mounted.'], + throws: ['RemoteError when no settings provider is mounted.'], }, { signature: '@Remote canOpenAgentPresetDirectory(): boolean', @@ -1971,35 +1971,35 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Merge a patch into one namespace\'s stored user section.', parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], returns: 'the namespace\'s redacted view after the write.', - throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { signature: '@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise', description: 'Replace one namespace\'s stored user section wholesale.', parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], returns: 'the namespace\'s redacted view after the write.', - throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise', description: 'Apply path-addressed edits to one namespace\'s user section, resolved against the section as stored rather than against whatever the caller last read, then answer with that namespace\'s new redacted view.', parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }], returns: 'the namespace\'s redacted view after the write.', - throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], + throws: ['RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, { signature: '@Remote async openSettingsDocument(signal: AbortSignal): Promise', description: 'Materialize the provider-owned settings document and open it in a native text editor.', parameters: [{ name: 'signal', description: 'caller lifetime; abort terminates preparation or the native command.' }], returns: 'confirmation after the native opener accepts the document.', - throws: ['TypertRemoteFailure when no document exists, preparation fails, or opening fails.'], + throws: ['RemoteError when no document exists, preparation fails, or opening fails.'], }, { signature: '@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise', description: 'Open one user-authored Agent preset directory or return its path when no native opener exists.', parameters: [{ name: 'agentPreset', description: 'preset id resolved against Host-owned roots.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }], returns: 'an opened confirmation or the resolved directory for text display.', - throws: ['TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened.'], + throws: ['RemoteError when the preset is missing, read-only, invalid, or cannot be opened.'], }, ], }, @@ -2236,21 +2236,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Remote face of listChildren for one browser: the durable listing plus live Agent activity and the delivery-time parent availability hint. Parent availability is a hint; prompt performs the authoritative check. Named apart from the provider-name list, which owns the member.', parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'carrier cancellation forwarded to Session queries.' }], returns: 'the catalog view for that parent.', - throws: ['{TypertRemoteFailure} `bad-request` for an empty parent id, `cancelled` for an aborted read, `subagent-projections-unavailable` when the deployment has no projection registry, otherwise `internal`.'], + throws: ['{RemoteError} `gateway/bad-request` for an empty parent id, `gateway/cancelled` for an aborted read, `subagent/projections-unavailable` when the deployment has no projection registry, otherwise `gateway/internal`.'], }, { signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise', description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call.', parameters: [{ name: 'request', description: 'durable address, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }], returns: 'the accepted message\'s inbox identity.', - throws: ['{TypertRemoteFailure} `bad-request`, `invalid-time-zone`, `subagent-parent-unavailable`, `subagent-not-resumable`, `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or `internal`.'], + throws: ['{RemoteError} `gateway/bad-request`, `subagent/attachment-unsupported`, `subagent/invalid-time-zone`, `subagent/parent-unavailable`, `subagent/not-resumable`, `subagent/unauthorized`, `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`.'], }, { signature: '@Remote(\'interruptByParent\') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\', ): SubagentInterruptReceipt', description: 'Remote face of interrupt under one durable parent address. No catalog, history, persistence, or parent Agent lookup runs: the core primitive alone authorizes the address against the live Activation, which is what keeps a live child interruptible while its parent Agent is offline. Absent, idle, and already-completed targets are accepted no-ops there.', parameters: [{ name: 'childSessionId', description: 'durable child session id to interrupt.' }, { name: 'parentSessionId', description: 'durable direct parent whose authority is claimed.' }, { name: 'mode', description: 'required continuable-address discriminator.' }], returns: 'acknowledgement that the cancel signal was admitted, not that the target is quiescent.', - throws: ['{TypertRemoteFailure} `bad-request` for an empty id, `subagent-unauthorized` when the address does not own the live target, otherwise `internal`.'], + throws: ['{RemoteError} `gateway/bad-request` for an empty id, `subagent/unauthorized` when the address does not own the live target, otherwise `gateway/internal`.'], }, { signature: 'registerProvider(provider: SubagentProvider): () => void', @@ -3444,7 +3444,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApiSessionAgentError', - declaration: 'export type ApiSessionAgentError = Extract;', + declaration: 'export type ApiSessionAgentError = RemoteError<\'session/not-found\' | \'session/agent-busy\' | \'gateway/internal\'>;', }, { name: 'ApiSessionAgentResult', @@ -3962,6 +3962,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EncodedImageAttachment', declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', }, + { + name: 'EncodedImagePromptBlock', + declaration: 'export interface EncodedImagePromptBlock extends EncodedImageAttachment {\n readonly type: \'image\';\n}', + }, { name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', @@ -4602,6 +4606,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', }, + { + name: 'RemoteError', + declaration: 'export class RemoteError
extends Error {\n readonly isDSHRemoteGatewayError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', + }, + { + name: 'RemoteErrorCode', + declaration: 'export type RemoteErrorCode = keyof RemoteErrorDetailsMap;', + }, + { + name: 'RemoteErrorDetailsMap', + declaration: 'export interface RemoteErrorDetailsMap {\n \'gateway/bad-request\': {\n readonly issues?: readonly object[];\n };\n \'gateway/cancelled\': {};\n \'gateway/internal\': {};\n}', + }, { name: 'RemoteEventHostInfo', declaration: 'export interface RemoteEventHostInfo {\n readonly home: string;\n}', @@ -4786,14 +4802,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionCreateValue', declaration: 'export interface SessionCreateValue {\n readonly sessionId: SessionId;\n readonly agentPreset?: string;\n}', }, - { - name: 'SessionError', - declaration: 'export type SessionError = {\n [Code in keyof SessionErrorDetailsMap]: {\n readonly code: Code;\n readonly message: string;\n readonly details: SessionErrorDetailsMap[Code];\n };\n}[keyof SessionErrorDetailsMap];', - }, - { - name: 'SessionErrorDetailsMap', - declaration: 'export interface SessionErrorDetailsMap {\n \'bad-request\': Record;\n cancelled: Record;\n \'session-not-found\': {\n readonly sessionId: SessionId;\n };\n \'model-unavailable\': {\n readonly provider: string;\n readonly model: string;\n };\n \'session-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedCwd: string;\n readonly existingCwd?: string;\n };\n \'invalid-time-zone\': {\n readonly value: string;\n };\n \'workspace-attach-failed\': {\n readonly sessionId: SessionId;\n readonly workspaceId: string;\n };\n \'workspace-not-found\': {\n readonly workspaceId: string;\n };\n \'agent-preset-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedPreset: string;\n readonly existingPreset?: string;\n };\n \'agent-preset-not-found\': {\n readonly agentPreset: string;\n readonly available: readonly string[];\n };\n \'agent-preset-invalid\': {\n readonly agentPreset: string;\n readonly reason: string;\n };\n \'agent-busy\': {\n readonly reason: string;\n };\n \'attachment-error\': {\n readonly reason: string;\n };\n \'queue-item-not-found\': {\n readonly itemId: MessageId;\n };\n \'steer-unavailable\': {\n readonly itemId: MessageId;\n };\n \'title-invalid\': {\n readonly sessionId: SessionId;\n };\n \'fork-unavailable\': {\n readonly sessionId: SessionId;\n /* …truncated — full shape in source */', - }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -5374,13 +5382,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentListEntry', declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', }, + { + name: 'SubagentPromptContentPart', + declaration: 'export type SubagentPromptContentPart = ContentBlock | EncodedImagePromptBlock;', + }, { name: 'SubagentPromptReceipt', declaration: 'export interface SubagentPromptReceipt {\n readonly messageId: MessageId;\n}', }, { name: 'SubagentPromptRequest', - declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: ContentBlock[];\n readonly clientTimeZone?: string;\n}', + declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: readonly SubagentPromptContentPart[];\n readonly clientTimeZone?: string;\n}', }, { name: 'SubagentPromptRequestId', diff --git a/snapshots/web/message-feedback-protocol/protocol.expected.json b/snapshots/web/message-feedback-protocol/protocol.expected.json index 536bad41f5..148afb38b5 100644 --- a/snapshots/web/message-feedback-protocol/protocol.expected.json +++ b/snapshots/web/message-feedback-protocol/protocol.expected.json @@ -18,9 +18,12 @@ "result": { "ok": false, "error": { - "code": "internal", + "code": "gateway/input-invalid", "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation", - "details": {} + "details": { + "endpoint": "messageFeedback/put", + "field": "request" + } } } } From a4f7193d243daffb55814f1fea9f8ce0a32a4715 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:01:54 +0800 Subject: [PATCH 058/429] docs: drop the Remote-failure bullet from packages/AGENTS.md The rule text lives in the cookbook and the failure-vocabulary Agent Note; the AGENTS.md roster and its 675-word budget stay as they were. --- packages/AGENTS.md | 1 - scripts/doc-budgets.manifest.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e1a22cabd1..af47b16740 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). -- **Remote failures are one `RemoteError` plus a merged code table** (`RemoteErrorDetailsMap`, `/`): no domain error classes or mapping functions; discriminate by `code`, never `instanceof`; call `ctx.remote` directly without narrowing or wrappers; client `src` never constructs `RemoteError` ([how-to](../docs/cookbook/adding-a-remote-api.md), [rationale](../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md)). [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 6c93dc54fd..1f7f25cb2a 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -5,6 +5,6 @@ "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, - "packages/AGENTS.md": 714, + "packages/AGENTS.md": 675, "packages/README.md": 994 } From f9e8fc8f8af36fceafee4a2c41f506f29eb9c51a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:28:51 +0800 Subject: [PATCH 059/429] fix(api): identity-stable $host facts and a whole-record host info hook - api/gateway: $host caches its RemoteHostFacts record and mints a new one only when home changes, so snapshot readers compare by reference; identity pinned in the client spec. - client/ui-tool, client/ui-workspace: the hooks channel exposes the host facts record as hostInfo and components select the field they need (useHostInfo(info => info.home)); row-component contracts are unchanged. --- packages/api/gateway/src/client/index.ts | 11 ++++++++--- .../api/gateway/tests/gateway.client.spec.ts | 12 +++++++++--- packages/client/ui-tool/src/client/apply.ts | 10 ++++------ .../client/ui-tool/src/client/contract/slots.ts | 17 +++++++++-------- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 4 ++-- .../ui-tool/src/client/tool/ToolDetails.tsx | 6 +++--- .../tests/tool-call-tree.client.spec.tsx | 2 +- .../tests/tool-details-render.client.tsx | 2 +- .../ui-workspace/src/client/contract/slots.ts | 11 ++++++----- .../client/ui-workspace/src/client/index.ts | 10 ++++------ .../src/client/rows/WorkspaceBrowser.tsx | 4 ++-- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../tests/workspace-browser.client.spec.tsx | 4 ++-- 14 files changed, 53 insertions(+), 44 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 303f311d6a..accf7cafb0 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -144,6 +144,7 @@ class ClientRemoteService extends Service implements ClientRemote { private readonly ownerCtx: Context private readonly connection: ConnectionHandle private readonly namespaces = new Map() + private hostFacts: RemoteHostFacts | undefined private readonly streams = new RemoteStreamMuxClient() private readonly events: ClientRemoteEvents private mutations = Promise.resolve() @@ -183,10 +184,14 @@ class ClientRemoteService extends Service implements ClientRemote { } get $host(): RemoteHostFacts { - return { - home: this.connection.generation.getSnapshot()?.host.home, - isLoopback: this.connection.isLoopback, + // Identity-stable: readers (useSyncExternalStore snapshots, memo inputs) + // compare by reference, so a fresh object is minted only when the fact + // itself changed. isLoopback is fixed for the page lifetime. + const home = this.connection.generation.getSnapshot()?.host.home + if (this.hostFacts === undefined || this.hostFacts.home !== home) { + this.hostFacts = { home, isLoopback: this.connection.isLoopback } } + return this.hostFacts } async $mount(contribution: TypertRemoteContribution): ReturnType { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index b6a1d12414..d0af7669e9 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -569,13 +569,19 @@ describe('Client Remote transport readiness', () => { await client const remote = ctx.remote - expect(remote.$host).toEqual({ home: undefined, isLoopback: true }) + const beforeReady = remote.$host + expect(beforeReady).toEqual({ home: undefined, isLoopback: true }) + expect(remote.$host).toBe(beforeReady) + live.snapshot = { id: 1, host: { home: '/hosts/primary' } } - expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + const afterReady = remote.$host + expect(afterReady).toEqual({ home: '/hosts/primary', isLoopback: true }) + expect(afterReady).not.toBe(beforeReady) + expect(remote.$host).toBe(afterReady) withdraw() expect(ctx.get('connection')).toBeUndefined() - expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + expect(remote.$host).toBe(afterReady) }) it('starts after Loader settlement and stops the owned loop on disposal', async () => { diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index b0eab4996d..1324e8cbed 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -24,14 +25,11 @@ export const inject = ['slots', 'remote'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - // Host facts are plain reads; a reset is what announces the generation that - // published them, so the views re-read on it instead of freezing the value - // the entry's first render saw (inject results are memoized per registration). - const hostHome: HostObservable = { - getSnapshot: () => ctx.remote.$host.home, + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, subscribe: listener => ctx.on('connection/reset', listener), } - const toolInject = () => ({ hooks: { hostHome } }) + const toolInject = () => ({ hooks: { hostInfo } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index fc38496e6d..703e102ac4 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -2,6 +2,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/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' @@ -48,15 +49,15 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostHomeInjected = { +export type ToolHostInfoInjected = { hooks: { /** - * Host account home, absent until the Connection is ready. A hook rather - * than a value: the renderer memoizes an entry's inject result for the - * registration's lifetime, so a home read there would freeze at whatever - * the first render saw. + * Fixed Host facts, reached through a hook rather than injected as values: + * the renderer memoizes an entry's inject result for the registration's + * lifetime, so facts read there would freeze at whatever the first render + * saw. Select the field the view needs (`info => info.home`). */ - hostHome: HostObservable + hostInfo: HostObservable } } @@ -64,9 +65,9 @@ export type ToolHostHomeInjected = { export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 19df19e054..fc65940d0f 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostHomeInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostInfoInjected, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 7e10afd82c..2a11948077 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostHome, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostInfo, t, }: ToolTreeProps) { - const home = useHostHome(value => value) + const home = useHostInfo(info => info.home) const block = node.data.root return ( ) { - const home = useHostHome(value => value) + block, cwd, useHostInfo, t, +}: Pick) { + const home = useHostInfo(info => info.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) 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 26743e3387..84ac946e1e 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 @@ -49,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'], + useHostInfo: ((selector: (info: { home: string | undefined }) => unknown) => selector({ home })) as ToolTreeProps['useHostInfo'], t, } as unknown as ToolTreeProps } 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 756d71877e..43b3286b69 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -157,7 +157,7 @@ export function renderToolDetails( return selector(home)} + useHostInfo={selector => selector({ home, isLoopback: true })} t={t} /> } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 7bd9d670f4..df17251c38 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -28,6 +28,7 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { createWorkspaceViewStore } from '../stores.ts' @@ -89,12 +90,12 @@ export type DirectoryPickingHooks = PropsHooks info.home`). */ - hostHome: HostObservable + hostInfo: HostObservable } /** * Start a New Session in a Workspace: reuse-or-create its blank session and diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 2f55dd506d..7c8e1108dc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -9,6 +9,7 @@ * packages/client/AGENTS.md. */ import type { Context } from '@deepseek-ai/cordis' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -89,11 +90,8 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.slots.subscribe(hole, listener), }) const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') - // Host facts are plain reads; a reset is what announces the generation that - // published them, so the rows re-read on it instead of freezing the value - // the entry's first render saw (inject results are memoized per registration). - const hostHome: HostObservable = { - getSnapshot: () => ctx.remote.$host.home, + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, subscribe: listener => ctx.on('connection/reset', listener), } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') @@ -129,7 +127,7 @@ export function apply(ctx: Context): void { await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, hostHome }, + hooks: { directoryFlow: browserFlowSource, hostInfo }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 09295168c5..9d9dd376e4 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useHostHome, + useHostInfo, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useHostHome(value => value) + const home = useHostInfo(info => info.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 87536d4646..acc4b1cbc8 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -159,7 +159,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.hooks.hostHome.getSnapshot()).toBeUndefined() + expect(browser.hooks.hostInfo.getSnapshot()).toMatchObject({ home: undefined }) expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index afb973a6df..8ee31d37c9 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -84,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useHostHome: selector => selector(undefined), + useHostInfo: selector => selector({ home: undefined, isLoopback: true }), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -109,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useHostHome: selector => selector('/home/u'), + useHostInfo: selector => selector({ home: '/home/u', isLoopback: true }), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) From 02a542f49f9bfcdf75dc659f1f274acb01e943d6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:37:26 +0800 Subject: [PATCH 060/429] chore(docs): regenerate the slot catalog for the hostInfo hook rename --- .../cordis-client-runner/src/client/slot-catalog.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 4be9be57f0..729f8b936f 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -650,7 +650,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.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', }, { key: 'conversation.input.attachments', @@ -2113,7 +2113,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.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', }, { key: 'tool.call.toolview', @@ -2170,7 +2170,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:25', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:26', }, { key: 'tool.view.cordis', From 674a1e95a3702f5a927c10a6e45e0f3bf264db31 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:12:43 +0800 Subject: [PATCH 061/429] fix(api): rename the failure marker, harden cross-realm discrimination, and mark the packed-record violation - protocol: isDSHRemoteGatewayError -> isDSHRemoteError (the class is protocol-wide, not Gateway-specific); remoteErrorOf drops its instanceof Error precondition and tests the marker plus a string code structurally, so a marked failure from another realm no longer reads as a local defect. - session-controller: the live-follow packed-record protocol violation now throws a marked RemoteError('gateway/internal'), landing the session in openState=error instead of an unhandled rejection; pinned at the transport and session levels. - util-time: correct the invariant companion's @module name. - regenerate the tool-cordis catalog for the marker rename. --- .../tests/control-retry.client.spec.ts | 4 ++-- .../src/client/transport.ts | 7 +++++- .../tests/session.client.spec.ts | 23 +++++++++++++++++++ .../tests/transport.client.spec.ts | 6 ++++- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/typert/protocol/src/remote-error.ts | 9 +++++--- packages/util/time/src/invariant.ts | 4 ++-- 7 files changed, 45 insertions(+), 10 deletions(-) diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 29736817e0..c818d507e7 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -108,7 +108,7 @@ describe('RemoteStream', () => { ], carrierFailed) await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteGatewayError: true, + isDSHRemoteError: true, code: 'gateway/internal', message: 'isolated retry failed', details: {}, @@ -130,7 +130,7 @@ describe('RemoteStream', () => { }) await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteGatewayError: true, + isDSHRemoteError: true, code: 'gateway/internal', message: 'generation exploded', }) diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 69a9b934e1..da2361ad90 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -1,6 +1,7 @@ /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { RemoteJournalStream, RemoteSnapshotStream, @@ -60,7 +61,11 @@ function toSessionJournalChange( return { ...change, entries: historyEntries(change.entries) } case 'append': { if (change.entry.type !== 'event') { - throw new Error('session live stream emitted a packed history record') + throw new RemoteError( + 'gateway/internal', + 'session live stream emitted a packed history record', + {}, + ) } return { type: 'append', diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 565dcf99bf..8d7e4319d3 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -99,6 +99,29 @@ describe('Session open', () => { expect(api.followStarts).toHaveLength(2) }) + it('lands a packed live record in openState=error instead of crashing the stream loop', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + expect(session.getSnapshot().openState).toBe('open') + + // The live tail may carry only events; a packed record breaks that contract. + await api.pushFollow(SID, { + type: 'chunks', + event: { + type: 'chunkrow/text-chunks', + seq: 6, + time: 6, + data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] }, + }, + } as never) + + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + expect(session.getSnapshot().openError).toMatchObject({ + code: 'gateway/internal', message: 'session live stream emitted a packed history record', + }) + }) + it('lands a Gateway-marked stream failure in openState=error', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 6e00845e35..c5f50ad7de 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + isRemoteFailure, RemoteStream, RemoteStreamCarrierError, type RemoteStreamOptions, @@ -181,7 +182,10 @@ describe('Session Client stream adapters', () => { await stream.open({}) await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) - expect(failed.mock.calls[0]?.[0]).toMatchObject({ + const violation: unknown = failed.mock.calls[0]?.[0] + expect(isRemoteFailure(violation)).toBe(true) + expect(violation).toMatchObject({ + code: 'gateway/internal', message: 'session live stream emitted a packed history record', }) await stream.dispose() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 943f60b85d..740cb44e2c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4608,7 +4608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteError', - declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteGatewayError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', + declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', }, { name: 'RemoteErrorCode', diff --git a/packages/typert/protocol/src/remote-error.ts b/packages/typert/protocol/src/remote-error.ts index ccdd6cd1d8..8805d0e9d4 100644 --- a/packages/typert/protocol/src/remote-error.ts +++ b/packages/typert/protocol/src/remote-error.ts @@ -11,7 +11,7 @@ import type { RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure } from './ty */ export class RemoteError extends Error { /** Structural marker: cross-realm/bundle identification never uses instanceof. */ - readonly isDSHRemoteGatewayError: true = true + readonly isDSHRemoteError: true = true /** * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}. @@ -38,8 +38,11 @@ export class RemoteError extends * @returns the failure when the marker matches, otherwise undefined. */ export function remoteErrorOf(value: unknown): RemoteFailure | undefined { - if (value instanceof Error - && (value as { isDSHRemoteGatewayError?: unknown }).isDSHRemoteGatewayError === true) { + // Structural, not instanceof: an Error thrown in another realm (iframe, VM) + // fails instanceof Error here, so the marker plus the code field is the test. + if (typeof value === 'object' && value !== null + && (value as { isDSHRemoteError?: unknown }).isDSHRemoteError === true + && typeof (value as { code?: unknown }).code === 'string') { return value as unknown as RemoteFailure } return undefined diff --git a/packages/util/time/src/invariant.ts b/packages/util/time/src/invariant.ts index 2a52f0ffb3..81ded26371 100644 --- a/packages/util/time/src/invariant.ts +++ b/packages/util/time/src/invariant.ts @@ -1,6 +1,6 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-time`. - * @module @deepseek-ai/dsh-time/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-util-time`. + * @module @deepseek-ai/dsh-util-time/invariant */ /* jscpd:ignore-start */ From 73a723f37fe65a24e7663fab9bd6c06aebce112b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:12:46 +0800 Subject: [PATCH 062/429] docs(api): correct the api-gateway reference codes and the failure-vocabulary note - api-gateway reference (en/zh): gateway/lookup-unavailable and gateway/internal replace the pre-convergence codes, and the resolver paragraph states the RemoteError pass-through semantics. - failure-vocabulary note (en/zh): the package is dsh-util-time, the marker is isDSHRemoteError, and discrimination requires no instanceof at all. --- .../2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml | 4 ++-- .../2026-08-28-ctx-remote-failure-vocabulary.md | 8 ++++---- .../2026-08-28-ctx-remote-failure-vocabulary.zh.md | 8 ++++---- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml index 80bdc66709..e659db7435 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.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-28-ctx-remote-failure-vocabulary.md -2026-08-28-ctx-remote-failure-vocabulary.md: 90126d9c23a8f1a1067f5d98f34e24f537774a3a -2026-08-28-ctx-remote-failure-vocabulary.zh.md: c00aa87b30692fed6ae8121e4373f806d063a967 +2026-08-28-ctx-remote-failure-vocabulary.md: fe8cafb6116d73797e1dae07fb28fe52d42c9285 +2026-08-28-ctx-remote-failure-vocabulary.zh.md: 6b75aae454fb9e4d7c4622525220d1949c45404d diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md index 90126d9c23..fe8cafb611 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md @@ -16,11 +16,11 @@ Fixed Host facts bypassed `ctx.remote` too: the Host home came from `(ctx.get('c ## Decision -`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteGatewayError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult` keeps its shape. +`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult` keeps its shape. ```text export class RemoteError extends Error { - readonly isDSHRemoteGatewayError = true as const + readonly isDSHRemoteError: true = true constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) } @@ -41,11 +41,11 @@ A code has exactly one declaration site, and the site follows from both who prod - **Produced by several packages**: when two or more packages throw the same code, the declaration lands in the lowest layer both already depend on. `session/not-found` lands in `@deepseek-ai/dsh-session` (session-controller and workspace-controller both depend on it), and `workspace/not-found` lands in `@deepseek-ai/dsh-workspace` (no dependency edge exists between the two API packages, so the capability package is their only shared layer). - **Single producer**: a code only one package throws lands in that producer. `subagent/not-found` and `agent-preset/conflict` therefore live in session-controller — it is their only thrower in the repository, and neither the subagent nor the agent-presets table declares them. -What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem. +What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-util-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem. ## Discrimination by code -Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read the marker, never the class. +Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker plus a string `code` through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read those fields, never the class — the test does not even require `instanceof Error`, because an Error thrown in another realm fails that too. Business code usually needs neither function: the `ok: false` branch of `RemoteResult` is already a typed `RemoteFailure`, so `if (result.error.code === 'session/not-found')` narrows `details` to that code's shape with no cast. A site that must propagate the failure writes `throw result.error` — it is a real `Error`, with a working stack and `message`. diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md index c00aa87b30..6b75aae454 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md @@ -16,11 +16,11 @@ Host 固定事实同样绕过了 `ctx.remote`:Host home 取自 `(ctx.get('conn ## Decision -`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteGatewayError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult` 形状不变。 +`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult` 形状不变。 ```text export class RemoteError extends Error { - readonly isDSHRemoteGatewayError = true as const + readonly isDSHRemoteError: true = true constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) } @@ -41,11 +41,11 @@ export type RemoteResult = { ok: true; value: T } | { ok: false; error: Remot - **跨包共产**:两个及以上不同包抛同一个码时,声明落到双方都已依赖的最低层。`session/not-found` 落 `@deepseek-ai/dsh-session`(session-controller 与 workspace-controller 都依赖它),`workspace/not-found` 落 `@deepseek-ai/dsh-workspace`(session-controller 与 workspace-controller 之间没有依赖边,能力包是唯一共同下层)。 - **单一生产者**:只有一个包抛的码落生产者包。`subagent/not-found` 与 `agent-preset/conflict` 因此落 session-controller——全仓只有它抛这两个码,subagent 与 agent-presets 的码表里都没有它们。 -共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。 +共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-util-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。 ## Discrimination by code -判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看标记,不看类。 +判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记加一个字符串 `code`,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看这两个字段、不看类——连 `instanceof Error` 都不要求,因为另一个 realm 抛出的 Error 同样通不过它。 业务代码通常连这两个函数都不需要:`RemoteResult` 的 `ok: false` 分支已经是类型化的 `RemoteFailure`,`if (result.error.code === 'session/not-found')` 就把 `details` 窄化到该码的形状,无需 cast。需要向上抛的站点直接 `throw result.error`——它是真 `Error`,栈与 `message` 都成立。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index c5feee2c91..c6d598ecbc 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: 43b00bcff3da0adb1d53ac25534b65366bc42611 -api-gateway.zh.md: bb2711220fd75ef0119e1188c22a0af5ad4169a8 +api-gateway.md: 92d1bb5f0103fd4e2424992de9445f4768346f91 +api-gateway.zh.md: 3c3d95a5c125c592baf21579fb724101708b5cf8 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 43b00bcff3..92d1bb5f01 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -124,7 +124,7 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. -The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index bb2711220f..3c3d95a5c1 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -124,7 +124,7 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 -lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的陈旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 From 90505636cd49dad080277465a5d2a8be4b67d3f8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 11:15:32 +0800 Subject: [PATCH 063/429] test(ci): carry the hook budget and raise the Lefthook suite to the lane value The Windows coverage lane grants DSH_COVERAGE_TEST_TIMEOUT_MS=90000, but two paths declined it. scripts/install-lefthook.spec.ts took a describe-level 30_000, restated as a per-case constant on five cases. Every case drives spawned Git and Node subprocesses; the slowest costs 7.5 s on an idle host, so the ceiling carried roughly fourfold headroom and fired on branches that did not touch the file. The suite takes 90_000 and the redundant constant is removed. coverageTestTimeoutArgs raised --testTimeout and --expect.poll.timeout but left --hookTimeout at Vitest's separate 10 s default, which removeFixtureSafely's documented 10-second Windows retry window meets exactly. Raising only the test budget would move a contended suite's failure into its teardown. --- ...ws-lane-hook-and-lefthook-budget.i18n.yaml | 6 ++++ ...9-windows-lane-hook-and-lefthook-budget.md | 35 +++++++++++++++++++ ...indows-lane-hook-and-lefthook-budget.zh.md | 35 +++++++++++++++++++ scripts/coverage-partitions.spec.ts | 3 +- scripts/coverage-partitions.ts | 15 ++++++-- scripts/install-lefthook.spec.ts | 23 +++++++----- scripts/run-gates.spec.ts | 5 +-- scripts/run-gates.ts | 2 +- 8 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md create mode 100644 .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md diff --git a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml new file mode 100644 index 0000000000..172adc21af --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.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-29-windows-lane-hook-and-lefthook-budget.md +2026-08-29-windows-lane-hook-and-lefthook-budget.md: 6886e3ad4958d20a88a66df6a9e02f5a60a36a6a +2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md: 56c1e625d92f01e24f2268deb5d03040278da6af diff --git a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md new file mode 100644 index 0000000000..6886e3ad49 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md @@ -0,0 +1,35 @@ +# Agent Note: Hook budget and Lefthook suite budget on the Windows coverage lane + +Status: implemented + +English | [中文](2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md) + +## Problem + +Two facts kept the Windows coverage lane failing on branches that touched neither the suite nor the gate. + +[`scripts/install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) took a `describe`-level `{ timeout: 30_000 }`, restated as a `MULTI_PROCESS_TEST_TIMEOUT_MS` constant on five of its cases. Every case builds scratch worktrees and drives them through spawned Git and Node subprocesses, so the suite is bound by process creation rather than by its assertions. On an idle macOS host its slowest case costs 7.5 s, so the ceiling carried roughly fourfold headroom — where the [translation-pairing-merge suite](2026-08-27-translation-pairing-merge-budget.md) fired at 15 s with more than tenfold. Under the self-hosted Windows runners' multi-second process-creation spikes this suite has been observed reporting `Test timed out in 30000ms` on branches that did not touch it, and the two cases observed failing are its slowest and its seventh-slowest. + +Separately, `coverageTestTimeoutArgs` in [`scripts/coverage-partitions.ts`](../../../../scripts/coverage-partitions.ts) raised `--testTimeout` and `--expect.poll.timeout` from `DSH_COVERAGE_TEST_TIMEOUT_MS` but left `--hookTimeout` at Vitest's separate 10 s default. Setup and teardown pay the same contention the raised test budget accounts for: [`removeFixtureSafely`](../../../../scripts/test-fixture-cleanup.ts) retries Windows handle release across a documented 10-second window, so an `afterEach` that exercises that window meets the hook default exactly. Raising only the test budget moves a contended suite's failure from the case to its teardown rather than removing it. + +## Decision + +The Lefthook suite takes `{ timeout: 90_000 }`, matching `DSH_COVERAGE_TEST_TIMEOUT_MS` in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). The per-case constant is deleted rather than raised: it restated the `describe` value, and the translation-pairing-merge note already rejected per-case allowances because a later case added without one silently inherits a different ceiling. + +`coverageTestTimeoutArgs` emits `--hookTimeout` beside the other two arguments. One environment variable governs one budget for the work a contended lane must finish, whether that work sits in a case or in its setup and teardown. + +## Consequences + +A `git` or `node` spawn spike on the shared-volume runners no longer decides either suite's outcome, and a slow fixture teardown no longer fails a suite whose cases all passed. Neither value measures how long the work needs: the Lefthook suite's slowest case completes in about 7.5 s on an idle host, and a raised ceiling does not slow a passing run. + +Both budgets widen what counts as an acceptable duration, so a real slowdown into tens of seconds now passes where the previous ceilings would have caught it. That detection is traded away deliberately: those ceilings were firing on host contention rather than on regressions. + +The hook change applies wherever `DSH_COVERAGE_TEST_TIMEOUT_MS` is set, which today is the Windows coverage lane alone. Lanes that leave it unset keep every Vitest default, including the 10 s hook budget. + +## Alternatives considered + +**Give `--hookTimeout` its own environment variable.** Two knobs would describe one property of the host, and a lane that raised one without the other would reproduce this failure in the other direction. + +**Shorten the `removeFixtureSafely` retry window instead.** That trades a cleanup failure for temp residue on the shared self-hosted `/tmp`, which has twice exhausted the host's inode capacity. + +**Raise only the Lefthook suite and leave the hook default.** The suite's `afterEach` is exactly where its Windows `EPERM` cleanup failures appear, so the raised case budget would have surfaced the same run as a hook timeout. diff --git a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md new file mode 100644 index 0000000000..56c1e625d9 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Windows 覆盖率 lane 的 hook 预算与 Lefthook 套件预算 + +Status: implemented + +[English](2026-08-29-windows-lane-hook-and-lefthook-budget.md) | 中文 + +## 问题 + +两件事让 Windows 覆盖率 lane 在既没碰套件、也没碰 gate 的分支上持续失败。 + +[`scripts/install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 在 `describe` 层取 `{ timeout: 30_000 }`,并以 `MULTI_PROCESS_TEST_TIMEOUT_MS` 常量的形式在其中五个用例上重复了同一个值。每个用例都会建临时 worktree 并通过 spawn 的 Git 与 Node 子进程驱动它,因此这个套件受进程创建约束,而不是受它的断言约束。在空闲的 macOS 主机上,它最慢的用例耗时 7.5 秒,也就是说这个上限只有约四倍余量——而 [translation-pairing-merge 套件](2026-08-27-translation-pairing-merge-budget.zh.md)在十倍以上余量的 15 秒上限下仍然触发。在自托管 Windows runner 数秒级的进程创建尖峰下,这个套件曾在没有改动它的分支上报出 `Test timed out in 30000ms`,而被观察到失败的两个用例正是它最慢的那个和第七慢的那个。 + +另一件事是 [`scripts/coverage-partitions.ts`](../../../../scripts/coverage-partitions.ts) 里的 `coverageTestTimeoutArgs`:它用 `DSH_COVERAGE_TEST_TIMEOUT_MS` 抬高了 `--testTimeout` 和 `--expect.poll.timeout`,却把 `--hookTimeout` 留在 Vitest 独立的 10 秒默认值上。setup 与 teardown 承受的是被抬高的测试预算所针对的同一种争抢:[`removeFixtureSafely`](../../../../scripts/test-fixture-cleanup.ts) 会在一个注释写明的 10 秒窗口内重试 Windows 句柄释放,因此一个真正用满该窗口的 `afterEach` 恰好撞上 hook 默认值。只抬高测试预算,只是把一个受争抢套件的失败从用例挪到它的 teardown,而不是消除它。 + +## 决定 + +Lefthook 套件取 `{ timeout: 90_000 }`,与 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 里的 `DSH_COVERAGE_TEST_TIMEOUT_MS` 一致。逐用例常量被删除而不是被抬高:它只是重述了 `describe` 的取值,而 translation-pairing-merge 的 note 已经否决过逐用例余量——后续新增的用例若不带余量,就会静默继承另一个上限。 + +`coverageTestTimeoutArgs` 在原有两个参数旁边发出 `--hookTimeout`。一个环境变量管一份预算,覆盖受争抢的 lane 必须完成的工作,无论这份工作位于用例内还是位于它的 setup 与 teardown。 + +## 后果 + +共享卷 runner 上一次 `git` 或 `node` 的 spawn 尖峰不再决定这两个套件的结果,一次缓慢的 fixture teardown 也不再让一个用例全部通过的套件失败。两个取值都不是对「需要多久」的测量:Lefthook 套件最慢的用例在空闲主机上约 7.5 秒,而抬高上限不会让一次通过的运行变慢。 + +两份预算都放宽了「多长算可接受」,因此一个退化到几十秒的真实变慢现在会通过,而此前的上限会拦住它。这项检测能力是有意换掉的:那些上限触发的是宿主机争抢,不是回归。 + +hook 的改动在所有设置了 `DSH_COVERAGE_TEST_TIMEOUT_MS` 的地方生效,目前仅 Windows 覆盖率 lane 一处。不设置它的 lane 保持全部 Vitest 默认值,包括 10 秒的 hook 预算。 + +## 备选方案 + +**给 `--hookTimeout` 单独一个环境变量。**两个旋钮描述宿主机的同一个属性,而只抬高其中一个的 lane 会以相反的方向复现同一个失败。 + +**改为缩短 `removeFixtureSafely` 的重试窗口。**这是用清理失败换共享自托管 `/tmp` 上的临时目录残留,而该残留已经两次耗尽宿主机的 inode 容量。 + +**只抬高 Lefthook 套件,保留 hook 默认值。**该套件的 `afterEach` 正是它 Windows `EPERM` 清理失败出现的位置,所以被抬高的用例预算只会把同一次运行改成以 hook 超时的形式暴露。 diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 6c6e31baa2..6a1774cff4 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -90,10 +90,11 @@ describe('coverage partition count', () => { }) describe('coverage partition timeout', () => { - it('applies one configured timeout to tests and polling', () => { + it('applies one configured timeout to tests, polling, and hooks', () => { expect(coverageTestTimeoutArgs('30000')).toEqual([ '--testTimeout=30000', '--expect.poll.timeout=30000', + '--hookTimeout=30000', ]) }) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index 86d919115d..a2d0a2b503 100644 --- a/scripts/coverage-partitions.ts +++ b/scripts/coverage-partitions.ts @@ -12,7 +12,7 @@ export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS' /** Internal marker that suppresses reports and thresholds inside a partition process. */ export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE' -/** Environment variable overriding instrumented test and polling timeouts. */ +/** Environment variable overriding instrumented test, polling, and hook timeouts. */ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' /** One child command owned by the coverage coordinator. */ @@ -76,14 +76,23 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u return parsed } -/** Resolve the paired Vitest timeout arguments used by coverage partitions. */ +/** + * Resolve the paired Vitest timeout arguments used by coverage partitions. + * `--hookTimeout` travels with the test budget because setup and teardown pay + * the same host contention the raised test budget accounts for: fixtures that + * await child exit or retry Windows handle release spend that cost in + * `afterEach`, where Vitest's separate 10 s default would otherwise fail a + * suite whose cases all passed. + * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. + * @returns the Vitest arguments applying that budget, empty when unset. + */ export function coverageTestTimeoutArgs(raw: string | undefined): string[] { if (raw === undefined || raw === '') return [] const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) } - return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`] + return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`, `--hookTimeout=${raw}`] } /** Remove pnpm's package-script separator before forwarding Vitest arguments. */ diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index f0c76ead6f..0f0a63833d 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -23,9 +23,6 @@ const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url)) const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json'))) const fixtures: string[] = [] -// Multi-worktree cases spawn several Git and Node subprocesses; native Windows -// coverage concurrency can delay them without changing installer behavior. -const MULTI_PROCESS_TEST_TIMEOUT_MS = 30_000 interface Fixture { container: string @@ -211,7 +208,15 @@ function runInstaller( }) } -describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { +// Every case builds scratch worktrees and drives them through spawned Git and +// Node subprocesses, so the suite is bound by process creation rather than by +// its assertions. The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the +// Windows coverage lane passes as --testTimeout: a describe value overrides that +// flag rather than yielding to it, so a smaller one here lowers what the lane +// grants every case in this file, none of which carries an allowance of its own. +// Rationale and the paired hook budget are in +// .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md. +describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => { for (const [label, extraEnv] of [ ['CI', { CI: 'true' }], ['GitHub Actions', { GITHUB_ACTIONS: 'true' }], @@ -290,7 +295,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') - }, MULTI_PROCESS_TEST_TIMEOUT_MS) + }) it('replaces the owned hook path Git copies into a newly added worktree', async () => { const fixture = createFixture() @@ -315,7 +320,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { '# config=late-linked-worktree-config', ) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore) - }, MULTI_PROCESS_TEST_TIMEOUT_MS) + }) it('serializes concurrent installs and keeps repeated output stable', async () => { const fixture = createFixture() @@ -336,7 +341,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook) expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false) expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) - }, MULTI_PROCESS_TEST_TIMEOUT_MS) + }) it('waits for a concurrent installer to finish publishing its lock record', async () => { const fixture = createFixture() @@ -374,7 +379,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain( JSON.stringify(movedHooks), ) - }, MULTI_PROCESS_TEST_TIMEOUT_MS) + }) it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => { const fixture = createFixture() @@ -415,7 +420,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { expect(result.stderr).toContain('non-regular or multiply linked hook entry') expect(readFileSync(externalHook, 'utf8')).toBe(externalContent) } - }, MULTI_PROCESS_TEST_TIMEOUT_MS) + }) it('restores the marker-backed stale hook path when relocation reinstall fails', async () => { const fixture = createFixture() diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 7d6cb3dac9..0033a55958 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -208,7 +208,7 @@ describe('gate graph validation', () => { expect(completeBuiltBin?.after).not.toContain('docs-site-build') }) - it('applies one configured test and polling timeout to both coverage gates', () => { + it('applies one configured test, polling, and hook timeout to both coverage gates', () => { const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) @@ -216,6 +216,7 @@ describe('gate graph validation', () => { expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([ '--testTimeout=15000', '--expect.poll.timeout=15000', + '--hookTimeout=15000', ])) } }) @@ -226,7 +227,7 @@ describe('gate graph validation', () => { for (const id of ['coverage', 'coverage-exempt-heavy']) { expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([ - expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout)=/), + expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), ])) } }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 689089d814..f3052d6c25 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -569,7 +569,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // small share. A budget of 1 gives each gate 1 worker; lanes that need a strict // total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, // which keeps the gates from overlapping at all. -// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll +// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook // defaults together for instrumented lanes whose scheduling overhead exceeds // those defaults. Explicit fixture timeouts remain authoritative. function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { From ccfbbb443af1411ea6c44dad362f23ee9cae4491 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:16:57 +0800 Subject: [PATCH 064/429] refactor(connection): centralize websocket recovery --- packages/api/gateway/src/client/index.ts | 4 + .../api/gateway/src/client/remote-stream.ts | 8 +- .../api/gateway/src/client/stream-client.ts | 111 +++++++-------- packages/api/gateway/src/index.ts | 4 +- packages/api/gateway/src/stream-server.ts | 11 +- .../connection/src/client/connection.ts | 127 +++++++++++++++--- .../client/connection/src/client/index.ts | 77 ++++++++++- 7 files changed, 247 insertions(+), 95 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index accf7cafb0..853fc4242f 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -164,8 +164,12 @@ class ClientRemoteService extends Service implements ClientRemote { let loop: ReturnType | undefined const start = (): void => { if (disposed) return + if (connection.rpc.open === undefined) this.streams.start() loop = connection.start({ onConnected: () => { this.ownerCtx.emit('connection/reset') }, + onReconnectRequested: () => { + if (connection.rpc.open === undefined) this.streams.reconnect() + }, }) } const loader = ctx.get('loader') as LoaderReadiness | undefined diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index a2b018ae91..00b398afd7 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -31,10 +31,10 @@ export interface RemoteStreamOptions { /** * Reopens one logical Remote stream across carrier generations. * - * The Gateway owns physical retry timing, cancellation, and replacement. The - * domain consumer owns its opening item and every later item, and calls - * {@link RemoteStreamItem.accept} only after validating the opening - * baseline or cursor. + * Connection owns physical retry timing; Gateway performs each requested + * replacement. The domain consumer owns its opening item and every later + * item, and calls {@link RemoteStreamItem.accept} only after validating the + * opening baseline or cursor. */ export class RemoteStream implements AsyncIterable> { private readonly lifetime = new AbortController() diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 9616102cce..79663d249b 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -10,11 +10,7 @@ import { import { randomUUID } from '@deepseek-ai/dsh-util-crypto' const INTERNAL_BASE = 'http://dsh.internal' -const RECONNECT_BASE_MS = 500 -const RECONNECT_FACTOR = 2 -const RECONNECT_MAX_MS = 10_000 -/** One Host-reported Remote stream failure. */ /** Physical Remote stream socket failure that may be retried by a domain transport. */ export class RemoteStreamCarrierError extends Error { /** @@ -28,6 +24,7 @@ export class RemoteStreamCarrierError extends Error { } interface SocketWaiter { + readonly revision: number resolve(socket: WebSocket): void reject(error: unknown): void } @@ -37,21 +34,43 @@ export class RemoteStreamMuxClient { private socket: WebSocket | undefined private cancelCandidate: ((error: Error) => void) | undefined private keepAlive: Promise | undefined - private keepAliveAbort: AbortController | undefined + private revision = 0 private readonly streams = new Map() private readonly waiters = new Set() private running = false private disposed = false - /** Start the persistent physical connection; repeated calls are inert. */ + /** Ensure a physical attempt exists, following the current attempt once if needed. */ start(): void { - if (this.running || this.disposed) return + if (this.disposed) return this.running = true - this.maintain() + if (this.socket?.readyState === WebSocket.OPEN) return + const pending = this.keepAlive + if (pending === undefined) this.maintain() + else void pending.then(() => { this.maintain() }) + } + + /** Cancel the current socket or retry wait and start a fresh attempt immediately. */ + reconnect(): void { + if (!this.running || this.disposed) return + const failure = new RemoteStreamCarrierError('api gateway: Remote stream reconnect requested') + const pending = this.keepAlive + this.revision++ + this.cancelCandidate?.(failure) + const socket = this.socket + if (socket !== undefined) { + this.socket = undefined + this.failAll(failure) + socket.close(4000, 'reconnect requested') + } + if (pending === undefined) this.maintain() + else void pending.then(() => { this.maintain() }) } /** * Open one logical stream on the persistent physical connection. + * If no physical attempt is active, opening waits for Connection to request + * one or for the signal to abort. * @param endpoint - Typert Remote stream endpoint. * @param payload - endpoint request encoded on the wire. * @param signal - cancellation for this logical stream. @@ -62,7 +81,6 @@ export class RemoteStreamMuxClient { payload: unknown, signal: AbortSignal, ): AsyncGenerator { - this.start() signal.throwIfAborted() const streamId = randomUUID() const inbox = new StreamInbox() @@ -101,16 +119,15 @@ export class RemoteStreamMuxClient { } /** - * Permanently stop reconnecting, close the physical socket, and fail every active logical stream. - * @returns once the background connection loop has stopped. + * Permanently stop the carrier, close the physical socket, and fail every + * active logical stream. + * @returns once the active connection attempt has stopped. */ async close(): Promise { if (!this.disposed) { this.disposed = true this.running = false const error = new Error('api gateway: Remote stream client disposed') - this.keepAliveAbort?.abort(error) - this.keepAliveAbort = undefined this.failAll(error) for (const waiter of [...this.waiters]) waiter.reject(error) this.cancelCandidate?.(error) @@ -176,7 +193,7 @@ export class RemoteStreamMuxClient { signal.throwIfAborted() if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket) if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed')) - this.start() + if (!this.running) return Promise.reject(new Error('api gateway: Remote stream client not started')) return new Promise((resolve, reject) => { const aborted = (): void => { waiter.reject(signal.reason) } const cleanup = (): void => { @@ -184,6 +201,7 @@ export class RemoteStreamMuxClient { signal.removeEventListener('abort', aborted) } const waiter: SocketWaiter = { + revision: this.revision, resolve: (socket) => { cleanup() resolve(socket) @@ -223,49 +241,27 @@ export class RemoteStreamMuxClient { if (this.socket !== socket) return this.socket = undefined this.failAll(error) - this.maintain(error) } - private maintain(previousFailure?: Error): void { - if (!this.running) return - if (this.keepAlive !== undefined) { - void this.keepAlive.then(() => { this.maintain(previousFailure) }) - return - } - const abort = new AbortController() - this.keepAliveAbort = abort - const task = this.reconnect(abort.signal, previousFailure) + private maintain(): void { + if (!this.running || this.disposed) return + if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return + const revision = this.revision + const task = this.connect().then( + () => undefined, + (error: unknown) => { + if (!this.running) return + for (const waiter of [...this.waiters]) { + if (waiter.revision <= revision) waiter.reject(error) + } + }, + ) this.keepAlive = task void task.then(() => { this.keepAlive = undefined - this.keepAliveAbort = undefined }) } - private async reconnect(signal: AbortSignal, previousFailure?: Error): Promise { - let attempt = 0 - let failure = previousFailure - while (this.isRunning(signal) && this.socket?.readyState !== WebSocket.OPEN) { - if (failure !== undefined) { - attempt += 1 - console.warn(`[api-gateway] Remote stream connection unavailable, retry #${String(attempt)}`, failure) - await sleep(backoffDelay(attempt), signal) - if (!this.isRunning(signal)) return - } - try { - await this.connect() - return - } catch (error) { - if (!this.isRunning(signal)) return - failure = error as Error - } - } - } - - private isRunning(signal: AbortSignal): boolean { - return this.running && !signal.aborted - } - private failAll(error: unknown): void { for (const stream of this.streams.values()) stream.fail(error) } @@ -275,23 +271,6 @@ export class RemoteStreamMuxClient { } } -function backoffDelay(attempt: number): number { - const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * RECONNECT_FACTOR ** Math.max(0, attempt - 1)) - return cap / 2 + Math.random() * (cap / 2) -} - -function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve) => { - const timer = setTimeout(done, ms) - signal.addEventListener('abort', done, { once: true }) - function done(): void { - clearTimeout(timer) - signal.removeEventListener('abort', done) - resolve() - } - }) -} - class StreamInbox { private readonly frames: RemoteStreamServerMessage[] = [] private wake: (() => void) | undefined diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 8610dc2881..25650ea2da 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -112,11 +112,11 @@ interface PendingRemoteEvent { type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal -const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000 +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000 /** Gateway transport configuration. */ export interface Config { - /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number } diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 28a3589542..9f0d5d4cab 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -23,6 +23,7 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() + private readonly heartbeatAlive = new WeakMap() private heartbeatTimer: NodeJS.Timeout | undefined /** @@ -44,6 +45,8 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + this.heartbeatAlive.set(websocket, true) + websocket.on('pong', () => { this.heartbeatAlive.set(websocket, true) }) this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() @@ -71,7 +74,13 @@ export class RemoteStreamMuxServer { if (this.heartbeatTimer !== undefined) return this.heartbeatTimer = setInterval(() => { for (const socket of this.server.clients) { - if (socket.readyState === WebSocket.OPEN) socket.ping() + if (socket.readyState !== WebSocket.OPEN) continue + if (this.heartbeatAlive.get(socket) === false) { + socket.terminate() + continue + } + this.heartbeatAlive.set(socket, false) + socket.ping() } }, this.heartbeatIntervalMs) this.heartbeatTimer.unref() diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 17e946c80e..3020c80326 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -12,12 +12,11 @@ export interface ConnectionGeneration { readonly host: ConnectionHostInfo } -/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the - * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ +/** Reconnect/backoff tunables. All fields are optional; defaults are below. */ export interface ConnectionConfig { /** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */ backoffBaseMs?: number - /** Exponential growth factor per consecutive failed attempt. */ + /** Exponential growth factor per failed attempt; values at or below 1 make the base tier final. */ backoffFactor?: number /** Upper bound for the backoff cap in ms. */ backoffMaxMs?: number @@ -32,6 +31,9 @@ const CONNECTION_DEFAULTS: Required = { generationReadyTimeoutMs: 3_000, } +const MANUAL_RECONNECT = new Error('connection: manual reconnect requested') +const NETWORK_STATE_CHANGED = new Error('connection: browser network state changed') + function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { const t = setTimeout(done, ms) @@ -44,17 +46,27 @@ function sleep(ms: number, signal: AbortSignal): Promise { }) } -/** Coarse connection state for the UI: 'connected' after each generation's handshake, - * 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */ -export type ConnectionState = 'connected' | 'reconnecting' +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +/** Connection lifecycle state published after the first attempt has an outcome. */ +export type ConnectionState = + | 'connected' + | 'disconnected' + | 'connecting' /** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { /** After the generation source reports ready, first connect included. */ onConnected?: (host: ConnectionHostInfo) => void - /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect - * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ + /** State transitions after the initial attempt has an outcome. Equivalent states are deduplicated. */ onStateChange?: (state: ConnectionState) => void + /** Start one fresh physical-carrier attempt before each logical retry. */ + onReconnectRequested?: () => void } /** @@ -79,8 +91,11 @@ export class ConnectionController { private generation = 0 private attempt = 0 private current: AbortController | null = null + private retryDelay: AbortController | null = null private running = false - private lastState: ConnectionState | null = null + private immediateRetry = false + private networkAvailable = true + private lastState: ConnectionState | undefined private readonly config: Required constructor( @@ -103,14 +118,53 @@ export class ConnectionController { this.running = false this.current?.abort() this.current = null + this.retryDelay?.abort() + this.retryDelay = null + } + + /** Reset the retry sequence and replace the current generation or retry delay immediately. */ + reconnect(): void { + if (!this.running) return + this.attempt = 0 + this.immediateRetry = true + this.emitState('connecting') + if (!this.isRunning()) return + this.current?.abort(MANUAL_RECONNECT) + this.retryDelay?.abort(MANUAL_RECONNECT) + } + + /** + * Suspend automatic retries while offline and restart backoff when the network returns. + * @param available - whether the browser reports network access. + */ + setNetworkAvailable(available: boolean): void { + if (this.networkAvailable === available) return + this.networkAvailable = available + this.attempt = 0 + this.immediateRetry = false + if (!this.running) return + this.emitState(available ? 'connecting' : 'disconnected') + if (!this.isRunning()) return + this.current?.abort(NETWORK_STATE_CHANGED) + this.retryDelay?.abort(NETWORK_STATE_CHANGED) + } + + private backoffCap(attempt: number): number { + const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config + return Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1)) } private backoffDelay(attempt: number): number { - const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config - const cap = Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1)) + const cap = this.backoffCap(attempt) return cap / 2 + Math.random() * (cap / 2) } + private isFinalBackoffTier(attempt: number): boolean { + const cap = this.backoffCap(attempt) + const nextCap = this.backoffCap(attempt + 1) + return cap >= this.config.backoffMaxMs || !Number.isFinite(nextCap) || nextCap <= cap + } + /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */ private isRunning(): boolean { return this.running @@ -122,7 +176,49 @@ export class ConnectionController { } private async loop(): Promise { + let retry = false while (this.running) { + if (!this.networkAvailable && !this.immediateRetry) { + const retryDelay = new AbortController() + this.retryDelay = retryDelay + this.emitState('disconnected') + await waitForAbort(retryDelay.signal) + if (this.retryDelay === retryDelay) this.retryDelay = null + if (!this.isRunning()) return + retry = true + continue + } + + let manualAttempt = false + if (retry) { + const immediate = this.immediateRetry + this.immediateRetry = false + if (immediate) this.attempt = 0 + manualAttempt = immediate + if (!immediate && this.attempt > 0 && this.isFinalBackoffTier(this.attempt)) { + const retryDelay = new AbortController() + this.retryDelay = retryDelay + this.emitState('disconnected') + await waitForAbort(retryDelay.signal) + if (this.retryDelay === retryDelay) this.retryDelay = null + continue + } + const attempt = ++this.attempt + this.emitState('connecting') + if (!this.isRunning()) return + if (!immediate) { + const retryDelay = new AbortController() + this.retryDelay = retryDelay + await sleep(this.backoffDelay(attempt), retryDelay.signal) + if (this.retryDelay === retryDelay) this.retryDelay = null + if (!this.isRunning()) return + if (retryDelay.signal.aborted) continue + } + console.warn(`[connection] connection lost, retry #${String(attempt)}`) + this.callSink(() => { this.sinks.onReconnectRequested?.() }) + if (!this.isRunning()) return + } + const gen = ++this.generation const ac = new AbortController() this.current = ac @@ -182,17 +278,14 @@ export class ConnectionController { this.callSink(() => { this.sinks.onConnected?.(host) }) } } catch { - // Transport failure: treat as generation failure, fall through to the shared backoff. + // Transport failure: treat as generation failure, then enter the shared retry path. if (!ac.signal.aborted) ac.abort() } await failed if (!this.isRunning()) return - this.emitState('reconnecting') - this.attempt += 1 - console.warn(`[connection] connection lost, retry #${this.attempt}`) - const idle = new AbortController() - await sleep(this.backoffDelay(this.attempt), idle.signal) + if (manualAttempt) this.attempt = 0 + retry = true } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 325834b69e..482ef2db91 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,6 +9,7 @@ import { type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, + type ConnectionState, } from './connection.ts' import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' @@ -61,6 +62,14 @@ export interface ConnectionGenerationState { subscribe(listener: () => void): () => void } +/** Observable recovery lifecycle of the owned Connection loop. */ +export interface ConnectionStateSource { + /** Current state, or undefined before the first connection outcome. */ + getSnapshot(): ConnectionState | undefined + /** Subscribe to state changes. */ + subscribe(listener: () => void): () => void +} + /** Required services (none — this is the wire root). */ export const inject: string[] = [] @@ -111,8 +120,12 @@ export interface ConnectionHandle { readonly isLoopback: boolean /** Current Remote event generation and the Host facts carried by its opening frame. */ readonly generation: ConnectionGenerationState + /** Current recovery lifecycle for connection-specific consumers. */ + readonly state: ConnectionStateSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc + /** Reset retry progression and replace the current attempt immediately. */ + reconnect(): void /** * Register the sole source defining Host generations. The source reports * ready only after its incremental listeners are attached. @@ -124,16 +137,44 @@ export interface ConnectionHandle { * Start the connect/reconnect loop with the consumer's state callbacks. * API Gateway owns the loop; a second call throws. * @param sinks - connection-state callbacks. - * @param config - reconnect/backoff tunables. - * @returns stop handle for the loop. + * @param config - reconnect timing tunables. + * @returns lifecycle controls for the loop. */ - start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void } + start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop +} + +/** Controls retained by the sole owner of a running connection loop. */ +export interface ConnectionLoop { + /** Stop the loop and withdraw its active generation. */ + stop(): void } interface ConnectionOwner { readonly token: object readonly source: ConnectionGenerationSource readonly controller: ConnectionController + readonly stopNetworkWatch: () => void +} + +interface BrowserNetworkTarget { + readonly navigator?: { readonly onLine?: boolean } + addEventListener(type: 'online' | 'offline', listener: () => void): void + removeEventListener(type: 'online' | 'offline', listener: () => void): void +} + +function watchBrowserNetwork(controller: ConnectionController): () => void { + const browser = (globalThis as { readonly window?: BrowserNetworkTarget }).window + const initiallyAvailable = browser?.navigator?.onLine + if (browser === undefined || initiallyAvailable === undefined) return () => {} + const online = (): void => { controller.setNetworkAvailable(true) } + const offline = (): void => { controller.setNetworkAvailable(false) } + controller.setNetworkAvailable(initiallyAvailable) + browser.addEventListener('online', online) + browser.addEventListener('offline', offline) + return () => { + browser.removeEventListener('online', online) + browser.removeEventListener('offline', offline) + } } /** @@ -150,7 +191,9 @@ export function apply(ctx: Context): void { let owner: ConnectionOwner | undefined let generationId = 0 let generation: ConnectionGeneration | undefined + let state: ConnectionState | undefined const generationListeners = new Set<() => void>() + const stateListeners = new Set<() => void>() const publishGeneration = (next: ConnectionGeneration | undefined): void => { if (Object.is(generation, next)) return generation = next @@ -162,11 +205,24 @@ export function apply(ctx: Context): void { } } } + const publishState = (next: ConnectionState | undefined): void => { + if (state === next) return + state = next + for (const listener of [...stateListeners]) { + try { + listener() + } catch (error) { + console.error('[connection] state listener threw:', error) + } + } + } const releaseOwner = (current: ConnectionOwner): void => { if (owner !== current) return owner = undefined + current.stopNetworkWatch() current.controller.stop() publishGeneration(undefined) + publishState(undefined) } const handle: ConnectionHandle = { isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), @@ -177,7 +233,17 @@ export function apply(ctx: Context): void { return () => { generationListeners.delete(listener) } }, }, + state: { + getSnapshot: () => state, + subscribe: (listener) => { + stateListeners.add(listener) + return () => { stateListeners.delete(listener) } + }, + }, rpc, + reconnect() { + owner?.controller.reconnect() + }, registerGenerationSource(source) { if (generationSource !== undefined) { throw new Error('connection: a generation source is already registered') @@ -205,14 +271,15 @@ export function apply(ctx: Context): void { sinks.onConnected?.(host) }, onStateChange: (state) => { - if (state === 'reconnecting') { + if (state !== 'connected') { publishGeneration(undefined) } if (!ownsGeneration()) return + publishState(state) sinks.onStateChange?.(state) }, }, config ?? {}) - const current = { token, source, controller } + const current = { token, source, controller, stopNetworkWatch: watchBrowserNetwork(controller) } owner = current controller.start() return { From 19b4d7f26c09a17741cefc324b776faa0ae0fa4e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:17:27 +0800 Subject: [PATCH 065/429] feat(web): add connection recovery indicator --- .../src/ConnectionBanner.module.css | 13 --- .../ui-primitives/src/ConnectionBanner.tsx | 16 --- .../src/ConnectionIndicator.module.css | 106 ++++++++++++++++++ .../ui-primitives/src/ConnectionIndicator.tsx | 94 ++++++++++++++++ packages/client/ui-primitives/src/index.ts | 3 +- .../client/ui-settings-general/package.json | 3 + .../src/client/SettingsRoot.module.css | 22 +++- .../src/client/SettingsRoot.tsx | 67 +++++++++-- .../ui-settings-general/src/client/index.ts | 7 +- .../src/client/shell-contract.ts | 14 ++- .../client/ui-settings-general/tsconfig.json | 3 + 11 files changed, 299 insertions(+), 49 deletions(-) delete mode 100644 packages/client/ui-primitives/src/ConnectionBanner.module.css delete mode 100644 packages/client/ui-primitives/src/ConnectionBanner.tsx create mode 100644 packages/client/ui-primitives/src/ConnectionIndicator.module.css create mode 100644 packages/client/ui-primitives/src/ConnectionIndicator.tsx diff --git a/packages/client/ui-primitives/src/ConnectionBanner.module.css b/packages/client/ui-primitives/src/ConnectionBanner.module.css deleted file mode 100644 index 16908770bb..0000000000 --- a/packages/client/ui-primitives/src/ConnectionBanner.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.banner { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 100; - padding: 4px 12px; - text-align: center; - font-size: 12px; - line-height: 18px; - background: var(--dsw-alias-state-error-primary); - color: var(--dsw-alias-label-primary-foreground); -} diff --git a/packages/client/ui-primitives/src/ConnectionBanner.tsx b/packages/client/ui-primitives/src/ConnectionBanner.tsx deleted file mode 100644 index 1cb38e99b2..0000000000 --- a/packages/client/ui-primitives/src/ConnectionBanner.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import css from './ConnectionBanner.module.css' - -/** - * Render the reconnecting banner. - * @param props.reconnecting - true while the connection is in backoff/retry. - * @param props.label - banner text; the owner passes localized copy (this - * package is cordis-free, so copy arrives via props). - * @returns the banner, or null when connected. - */ -export function ConnectionBanner({ reconnecting, label }: { - reconnecting: boolean - label: string -}) { - if (!reconnecting) return null - return
{label}
-} diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css new file mode 100644 index 0000000000..9c46f7a6e4 --- /dev/null +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -0,0 +1,106 @@ +.indicator { + flex: none; + display: inline-grid; + grid-template-columns: 14px max-content; + align-items: center; + column-gap: 4px; + height: 32px; + padding: 0 10px; + box-sizing: border-box; + border: none; + border-radius: 8px; + font-family: inherit; + font-size: 12px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; + transition: background-color 160ms ease-out, color 160ms ease-out; +} + +.warning { + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-label); + cursor: pointer; +} + +.warning:active { + background: color-mix( + in srgb, + var(--dsw-alias-state-warn-tertiary), + var(--dsw-alias-state-warn-primary) 10% + ); +} + +.warning:focus-visible { + outline: 2px solid var(--dsw-alias-state-warn-label); + outline-offset: 2px; +} + +.success { + background: var(--dsw-alias-state-success-tertiary); + color: var(--dsw-alias-state-success-primary); +} + +.icon { + display: grid; + place-items: center; + width: 14px; + height: 14px; +} + +.label { + display: grid; +} + +.stateLabel, +.hoverLabel, +.sizeLabel { + grid-area: 1 / 1; +} + +.sizeLabel { + visibility: hidden; +} + +.warning:is(:hover, :focus-visible) .stateLabel { + visibility: hidden; +} + +.hoverLabel { + visibility: hidden; +} + +.warning:is(:hover, :focus-visible) .hoverLabel { + visibility: visible; +} + +.dots { + display: inline-block; + width: 1.5em; + text-align: left; +} + +.secondDot { + animation: reveal-second-dot 1.5s step-end infinite; +} + +.thirdDot { + animation: reveal-third-dot 1.5s step-end infinite; +} + +@keyframes reveal-second-dot { + 0%, 33.32% { visibility: hidden; } + 33.33%, 100% { visibility: visible; } +} + +@keyframes reveal-third-dot { + 0%, 66.65% { visibility: hidden; } + 66.66%, 100% { visibility: visible; } +} + +@media (prefers-reduced-motion: reduce) { + .secondDot, + .thirdDot { + animation: none; + } +} diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.tsx b/packages/client/ui-primitives/src/ConnectionIndicator.tsx new file mode 100644 index 0000000000..2c549661df --- /dev/null +++ b/packages/client/ui-primitives/src/ConnectionIndicator.tsx @@ -0,0 +1,94 @@ +import { IconCheckOutline16, IconWarningOutline16 } from './icons/index.tsx' +import css from './ConnectionIndicator.module.css' + +/** Visual state rendered by {@link ConnectionIndicator}. */ +export type ConnectionIndicatorState = + | 'disconnected' + | 'connecting' + | 'recovered' + +/** + * Render an inline connection-recovery control. + * @param props.state - visible outage, retry-attempt, or recovered state. + * @param props.disconnectedLabel - localized outage text. + * @param props.reconnectLabel - localized action text shown on hover or focus. + * @param props.connectingLabel - localized retry text followed by the attempt dots. + * @param props.recoveredLabel - localized recovery confirmation. + * @param props.reconnectActionLabel - accessible label for the outage action. + * @param props.restartActionLabel - accessible label for replacing an active attempt. + * @param props.onReconnect - request an immediate reconnect attempt. + * @returns the indicator, or null when no connection feedback is active. + */ +export function ConnectionIndicator({ + state, + disconnectedLabel, + reconnectLabel, + connectingLabel, + recoveredLabel, + reconnectActionLabel, + restartActionLabel, + onReconnect, +}: { + state: ConnectionIndicatorState | undefined + disconnectedLabel: string + reconnectLabel: string + connectingLabel: string + recoveredLabel: string + reconnectActionLabel: string + restartActionLabel: string + onReconnect: () => void +}) { + if (state === undefined) return null + const sizeLabels = ( + <> + + + + + + ) + if (state === 'recovered') { + return ( +
+ + + {sizeLabels} + {recoveredLabel} + +
+ ) + } + + const connecting = state === 'connecting' + return ( + + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 2c2643112d..c5f88e5eee 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -21,7 +21,8 @@ export { Modal } from './Modal.tsx' export { OnboardingSurface } from './OnboardingSurface.tsx' export { RiskConfirmation } from './RiskConfirmation.tsx' export type { RiskConfirmationProps } from './RiskConfirmation.tsx' -export { ConnectionBanner } from './ConnectionBanner.tsx' +export { ConnectionIndicator } from './ConnectionIndicator.tsx' +export type { ConnectionIndicatorState } from './ConnectionIndicator.tsx' export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export type { BrandWordmarkProps } from './BrandWordmark.tsx' diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 2a21623ef0..3f4de05dd4 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -34,6 +34,7 @@ "inject": [ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -51,6 +52,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", @@ -62,6 +64,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index d9c94a1781..f68b7a5d4e 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -1,11 +1,26 @@ -.trigger { +.triggerRow { flex: none; display: flex; align-items: center; gap: 8px; width: calc(100% + 4px); - height: 42px; margin: 4px -2px; +} + +.triggerRow.railRow { + width: 36px; + margin: 8px 0 10px; +} + +.trigger { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + width: auto; + height: 42px; + margin: 0; padding: 0 10px 0 8px; box-sizing: border-box; border: none; @@ -25,9 +40,10 @@ /* Rail trigger: the same 36x36 circle box as the other rail controls. */ .trigger.rail { + flex: none; width: 36px; height: 36px; - margin: 8px 0 10px; + margin: 0; justify-content: center; gap: 0; padding: 0; diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 5c5498738e..d5b1df190a 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -10,15 +10,19 @@ * sessions-derived empty-Hero fact is active. Visible dialog chrome belongs * to the step, so a mounted-but-deciding step paints nothing here. */ -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import clsx from 'clsx' import { + ConnectionIndicator, IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconPersonalizationOutline16, IconSettingsOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ConnectionIndicatorState } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts' import css from './SettingsRoot.module.css' +const RECOVERY_CONFIRMATION_MS = 2_000 + /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return @@ -102,10 +106,13 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP * @returns the settings shell element tree. */ export function SettingsRoot(props: SettingsRootComponentProps) { - const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props + const { + wide, reconnect, useConnectionState, useSections, useOnboardingSteps, useSessions, renderSlot, t, + } = props const [open, setOpen] = useState(false) const [activeId, setActiveId] = useState(undefined) const [completedOnboarding, setCompletedOnboarding] = useState>(() => new Set()) + const [showRecovery, setShowRecovery] = useState(false) const triggerButton = useRef(null) const wasOpen = useRef(open) const close = useCallback(() => { @@ -126,6 +133,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) { // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. const rows = useSections(s => s) + const connectionState = useConnectionState(state => state) + const previousConnectionState = useRef(connectionState) const onboardingSteps = useOnboardingSteps(s => s) const onboardingActive = useSessions(state => state.phase === 'ready' @@ -139,6 +148,19 @@ export function SettingsRoot(props: SettingsRootComponentProps) { setCompletedOnboarding(new Set()) }, [onboardingActive]) + useLayoutEffect(() => { + const previous = previousConnectionState.current + previousConnectionState.current = connectionState + if (connectionState !== 'connected') { + setShowRecovery(false) + return + } + if (previous !== 'disconnected' && previous !== 'connecting') return + setShowRecovery(true) + const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS) + return () => { window.clearTimeout(timeout) } + }, [connectionState]) + const completeOnboardingStep = useCallback((id: string) => { setCompletedOnboarding((previous) => { if (previous.has(id)) return previous @@ -146,18 +168,39 @@ export function SettingsRoot(props: SettingsRootComponentProps) { }) }, []) + let connectionIndicator: ConnectionIndicatorState | undefined + if (connectionState === 'disconnected') { + connectionIndicator = 'disconnected' + } else if (connectionState === 'connecting') { + connectionIndicator = 'connecting' + } else if (showRecovery) { + connectionIndicator = 'recovered' + } + return ( <> - +
+ + +
{open && ( ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries') + const connection = ctx.get('connection') as ConnectionHandle // Copy freshness is framework-owned: components read the standard `t` // seat, and the nav label is a thunk the owner resolves per render — no @@ -92,7 +94,9 @@ export function apply(ctx: ClientContext): void { let onboardingVersion = -1 let onboardingSteps: readonly SettingsOnboardingStep[] = [] const shellInjected = (): SettingsRootInjected => ({ + reconnect: () => { connection.reconnect() }, hooks: { + connectionState: connection.state, sections: { getSnapshot: () => { const version = ctx.slots.getVersion('settings.section') @@ -141,6 +145,7 @@ export function apply(ctx: ClientContext): void { }) ctx.slots.inject('sidebar.settings', () => ctx.slots.register({ name: 'sidebar.settings', + locale: NS, children: { 'settings.trigger': { kind: 'single', scope: 'root' }, 'settings.header': { kind: 'single', scope: 'root' }, diff --git a/packages/client/ui-settings-general/src/client/shell-contract.ts b/packages/client/ui-settings-general/src/client/shell-contract.ts index ad61b2111a..210f028711 100644 --- a/packages/client/ui-settings-general/src/client/shell-contract.ts +++ b/packages/client/ui-settings-general/src/client/shell-contract.ts @@ -6,7 +6,10 @@ * reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme. * The settings SLOT types (what registrants contribute) stay in ui-settings. */ -import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConnectionState } from '@deepseek-ai/dsh-client-connection/client' +import type { + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) // into every program that sees this contract. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -28,11 +31,15 @@ export interface SettingsOnboardingStep { /** * Registrant-private injected share of the settings shell (assembled in - * apply): the ledger's nav-row projection as a hooks-compartment source — - * the shell reads no locale state and subscribes through the bound hook. + * apply): connection state and ledger projections arrive as hook-compartment + * sources, while the reconnect command remains a plain callback. */ export type SettingsRootInjected = { + /** Request a fresh logical generation and physical WebSocket immediately. */ + reconnect: () => void hooks: { + /** Connection-owned state for the current Host connection. */ + connectionState: HostObservable /** settings.section ledger projected into ordered nav rows. */ sections: HostObservable /** settings.onboarding ledger projected into coordinator order. */ @@ -57,3 +64,4 @@ export type SettingsRootComponentProps = | 'settings.onboarding' > & InjectFace + & PropsLocale<'settings'> diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index e13a2f4ff6..f72ed7ef99 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../locale" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../../settings/settings" }, From 18480ff902e21388ca0f1419cb1f1d063d621d33 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:18:24 +0800 Subject: [PATCH 066/429] test(connection): verify and document recovery behavior --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 14 +- ...-session-history-and-event-transport.zh.md | 14 +- ...8-23-locale-owned-client-ui-copy.i18n.yaml | 4 +- .../2026-08-23-locale-owned-client-ui-copy.md | 2 +- ...26-08-23-locale-owned-client-ui-copy.zh.md | 2 +- ...-web-connection-recovery-control.i18n.yaml | 6 + ...6-08-28-web-connection-recovery-control.md | 41 ++ ...8-28-web-connection-recovery-control.zh.md | 41 ++ apps/web/tests/lifecycle-chrome.e2e.ts | 127 +++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 8 +- packages/api/gateway/README.zh.md | 8 +- .../gateway/tests/gateway-stream.host.spec.ts | 2 +- .../api/gateway/tests/gateway.client.spec.ts | 302 +++++++++---- .../gateway/tests/stream-server.host.spec.ts | 14 +- .../tests/client-apply.client.spec.ts | 2 + .../tests/transport.client.spec.ts | 2 + packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- .../tests/client-apply.client.spec.ts | 167 ++++++- .../tests/connection.client.spec.ts | 413 +++++++++++++++++- .../tests/generation.client.spec.ts | 4 +- packages/client/locale/src/locales/en.ts | 1 - packages/client/locale/src/locales/zh.ts | 1 - .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/tests/atoms.client.spec.tsx | 38 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 8 +- .../client/ui-settings-general/README.zh.md | 8 +- .../ui-settings-general/src/client/locales.ts | 12 + .../tests/apply.client.spec.ts | 10 +- .../tests/settings-root.client.spec.tsx | 69 ++- .../tests/shell.client.spec.ts | 20 +- .../src/client/api-catalog.ts | 14 +- pnpm-lock.yaml | 3 + .../connection-error.expected.md | 4 + 49 files changed, 1243 insertions(+), 184 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md create mode 100644 .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md create mode 100644 snapshots/web/lifecycle-chrome/connection-error.expected.md 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 df81608c10..19e4ac9d22 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: dedfe98ca2b3e64a56518dfa6157244e4d4c16df -2026-07-30-client-locale-full-rollout.zh.md: e9bd1ed19e8b485d812140ab044c779a2ce6e9d3 +2026-07-30-client-locale-full-rollout.md: 2d7c919d420f5681007843d5b8aae5c9c53cc275 +2026-07-30-client-locale-full-rollout.zh.md: 8546d06a365cabad50cd26c0f50e45e762671588 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 dedfe98ca2..2d7c919d42 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 @@ -16,7 +16,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **The built-in locale set is closed; the language catalog is extensible.** The package contributes only `zh` and `en`, and typed namespace registration continues to require that bilingual pair. An external client plugin adds a language through `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` and contributes partial translations through the existing single-locale dictionary registration; language definitions and dictionaries may register in either order. An external language id is its validated BCP 47 tag for preference storage, dictionary lookup, browser matching, and ``; `LocaleId` remains a string because the tag carries interoperable language semantics rather than opaque identity. The built-in `zh` definition retains its internal `zh-CN` document tag. Every added language names a registered fallback whose own definition supplies the next fallback, and the chain must terminate at `en`; unknown targets and cycles fail at registration. For each key, lookup walks that chain in the requested namespace, then repeats it in `common`, before displaying the key itself. The Host stores an open string preference; an unavailable saved id remains pending until its language registers, while removal returns an active selection to the available browser match or `en`. Catalog changes advance the `LocaleFace` revision so the Language row follows registration and disposal. -**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). +**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionIndicator`, 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). **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. 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 e9bd1ed19e..8546d06a36 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 @@ -16,7 +16,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **内置 locale 集合封闭,语言目录可扩展。** 本包只提供 `zh` 与 `en`,类型化命名空间注册仍要求这对双语字典。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加语言,并通过既有的单 locale 字典注册贡献不完整翻译;语言定义与字典可以按任意顺序注册。外部语言 id 是经过校验的 BCP 47 标签,同时用于偏好存储、字典查找、浏览器匹配和 ``;该标签承载可互操作的语言语义而非不透明身份,因此 `LocaleId` 保持 string。内置 `zh` 定义继续使用内部 `zh-CN` 文档标签。每个新增语言都声明一个已注册的 fallback,fallback 自身的定义给出下一层 fallback,整条链必须终止于 `en`;未知目标和循环在注册时失败。每个 key 先在请求的命名空间中沿链查找,再在 `common` 中重复同一条链,最后显示 key 本身。Host 存储开放字符串偏好;不可用的已保存 id 会保持待采用,直至对应语言注册;定义移除后,正在使用的选择会回落到可用的浏览器匹配或 `en`。目录变更推进 `LocaleFace` revision,使语言设置行跟随注册和 dispose。 -**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)负责。 +**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionIndicator` 和 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 服务运行;本地化应用激活后会替换其中的产品文案。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index a73eb1a62f..f717ba76e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 8f26b2977dceeb2085bf270ae603cd21d48157f5 -2026-08-18-session-history-and-event-transport.zh.md: 10edeff16695cac265f2026b300eb206838a53e4 +2026-08-18-session-history-and-event-transport.md: d35ed79dedd5592d15a27b0e1b952e66d80b268f +2026-08-18-session-history-and-event-transport.zh.md: 6e6ccf53e28c9a7ce76bb4aa5d80d94f39e11f10 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 8f26b2977d..d35ed79ded 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -60,11 +60,13 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do ### Connection generation and physical connections -The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. +The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream, but the mux performs no independent retry scheduling. -The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries. +The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. Before each Ping, the Host marks the socket as awaiting Pong and terminates it at the next interval if no Pong arrived. -After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. +After an initial connection failure or the loss of a connected socket, open logical streams end their current physical generation with `RemoteStreamCarrierError`. `ConnectionController` owns the bounded exponential retry schedule; each attempt asks the mux to replace any candidate or active socket exactly once before reopening `$events`. A user-requested reconnect resets the attempt sequence and bypasses the delay through the same path ([decision](../feature/2026-08-28-web-connection-recovery-control.md)). + +The browser's network-status events are inputs to the same Controller. `offline` withdraws the Connection generation and suspends automatic retries; the next `online` transition restarts the base backoff. These events never establish connectivity: only a fresh `$events` ready frame publishes a Connection generation. In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. @@ -74,11 +76,11 @@ The Host event source installs incremental listeners synchronously before return `ConnectionController` publishes `connected` only after `$events` readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. -Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` after backoff. +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` under its bounded backoff unless the browser is offline or a user requests an immediate retry. Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. -Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. +Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops retry delays, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. ### General Remote stream model @@ -330,7 +332,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re ## Verification -Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. +Gateway mux tests pin connection without logical streams, idle residency, one physical attempt per request, configurable Ping/Pong without application messages, active-stream carrier failure, cancellation, and no reconnect after disposal. Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 10edeff166..6e6ccf53e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -60,11 +60,13 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host ### Connection generation 与物理连接 -浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 +浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻,但 mux 不运行独立的 retry 调度。 -Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。 +Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。每次 Ping 前,Host 把 socket 标记为等待 Pong;若到下一间隔仍未收到 Pong,Host 会终止该 socket。 -首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 +首次建连失败或已连接 socket 丢失后,已打开的 logical stream 会以 `RemoteStreamCarrierError` 结束当前物理 generation。`ConnectionController` 拥有有界的指数 retry 调度;每次尝试都要求 mux 恰好一次替换候选或活动 socket,再重开 `$events`。用户要求的重连通过同一路径重置 attempt 序列并跳过等待(见[决策](../feature/2026-08-28-web-connection-recovery-control.zh.md))。 + +浏览器网络状态事件是同一 Controller 的输入。`offline` 会撤回 Connection generation 并暂停自动 retry;下一次 `online` 转换会从基础退避档重新开始。这些事件不会建立连接;只有新的 `$events` ready 帧才会发布 Connection generation。 进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 @@ -74,11 +76,11 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后 `ConnectionController` 只有在 `$events` ready 后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 -`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,退避后重新建立 `$events`。 +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,随后按有界退避重新建立 `$events`;浏览器离线时暂停,用户要求立即重试时则跳过等待。 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 -Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 +Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止重试等待,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 ### 通用 Remote stream 模型 @@ -330,7 +332,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace ## 验证 -Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、每次请求只做一次物理尝试、可配置且不产生应用消息的 Ping/Pong、活动 stream carrier failure、取消和 dispose 后不再重连。 Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。 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 index 8cf0ccbbd2..a0680163b7 100644 --- 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 @@ -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-23-locale-owned-client-ui-copy.md -2026-08-23-locale-owned-client-ui-copy.md: 7fa2d60f14253a74b2bd3df4398471905a32509b -2026-08-23-locale-owned-client-ui-copy.zh.md: 5515699bb1702d41726c57435b19a2256ee0b896 +2026-08-23-locale-owned-client-ui-copy.md: 5f645a34c386ba340c5a8d52e8bdef2258dd77bd +2026-08-23-locale-owned-client-ui-copy.zh.md: 996ef56b17637a4ca60b8793075e9975faf0e1e1 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 index 7fa2d60f14..5f645a34c3 100644 --- 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 @@ -12,7 +12,7 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d **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. +**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` 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. 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 index 5515699bb1..996ef56b17 100644 --- 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 @@ -12,7 +12,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整 **所有产品编写的 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;功能专属短语留在决定其语义的功能侧。 +**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 **本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。 diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml new file mode 100644 index 0000000000..81a25f4ac3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.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-28-web-connection-recovery-control.md +2026-08-28-web-connection-recovery-control.md: 7adf372498105bad643606cf7cd99c71406b634b +2026-08-28-web-connection-recovery-control.zh.md: c1172c530035590ebcd636e8735bc378c15bc69d diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md new file mode 100644 index 0000000000..7adf372498 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md @@ -0,0 +1,41 @@ +# Agent Note: Web connection recovery control + +Status: implemented + +English | [中文](2026-08-28-web-connection-recovery-control.zh.md) + +## Problem + +The Web Client automatically rebuilt its Remote event generation and physical WebSocket after a failure, but the page exposed neither the outage nor a user recovery action. Its logical-generation and physical-socket retry loops could also drift: a `retry #N` message could describe another logical generation while the browser still waited on the same physical connection candidate. The Host sent an idle WebSocket Ping only every 30 seconds, and a user could not request a fresh attempt after restoring the Host or network. + +## Decision + +The Host sends WebSocket Ping control frames every two seconds by default through the existing validated `websocketHeartbeatIntervalMs` configuration. Before each Ping it marks the socket as awaiting Pong; a socket still awaiting Pong at the next interval is terminated. `ConnectionController` is the sole retry scheduler. Online transport failures enter jittered exponential backoff whose cap starts at 500ms, doubles through 1s, 2s, 4s, and 8s, and stops growing at 10s; the actual delay is 50–100% of the cap. The failed retry in the 10s tier ends automatic recovery and publishes `disconnected`. Each physical retry publishes `connecting`, writes one `retry #N` warning, asks Gateway mux to replace any candidate or active socket exactly once, and reopens the internal `$events` stream. + +The Client Connection service exposes the identity-stable `ctx.connection.state` observable and `ctx.connection.reconnect()`. Its snapshot is undefined until the first connection outcome, then carries `disconnected`, `connecting`, or `connected`; equivalent states do not notify. Manual reconnect interrupts the current generation or retry delay, resets the attempt number, and starts retry 1 immediately through the same physical and logical path as automatic recovery. The browser's `offline` event immediately aborts active connection work, publishes `disconnected`, and suspends automatic retries. The next `online` transition publishes `connecting`, resets the attempt number, and starts again at the 500ms backoff tier; duplicate events do not create another loop. A fresh `$events` ready frame, rather than `navigator.onLine`, proves Host connectivity. Logical streams continue to own their baseline, cursor, and replay semantics after the replacement generation. + +The [Web Client architecture](../architecture/2026-07-19-gui-web-client-architecture.md), [Remote event delivery](../architecture/2026-08-10-remote-event-delivery.md), and [Session event transport](../architecture/2026-08-18-session-history-and-event-transport.md) retain their broader ownership decisions; this note supersedes only their former retry timing. + +The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Connection issue** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing. + +## Alternatives considered + +**Retry every two seconds without a terminal state.** Rejected because a long outage would create continuous connection traffic. The retained exponential policy retries quickly at first, becomes progressively quieter, and leaves a stable recovery action after the 10s tier fails. + +**Render a full-width `ConnectionBanner` at the top of the viewport.** Rejected because the status belongs beside the recovery action the user named, and a global overlay consumes unrelated page chrome. The primitive is the inline `ConnectionIndicator`; no `ConnectionBanner` compatibility export exists before the first tagged release. + +**Expose lifecycle control through `ctx.remote.$connection`.** Rejected because retry state and commands belong to the Connection service rather than the Remote method namespace. Direct `ctx.connection` use remains exceptional and is appropriate here because the indicator itself controls reconnection. + +**Retry only when the user clicks.** Rejected because recovery must remain automatic when the user is not watching the page; the button resets the backoff and bypasses its current wait. + +## Consequences + +Idle browser connections generate more frequent heartbeat traffic than the former default, while long outages stop generating connection attempts after the capped retry fails. Deployments may override the Host Ping interval. Gateway mux owns no second retry timer, so every `retry #N` warning corresponds to one Controller-requested physical attempt. + +A manual reconnect intentionally disrupts every logical Remote stream sharing the physical socket. Their existing generation supervisors restore state through fresh baselines or cursors, and one-way notifications remain non-replayed. + +The connection state and browser-network input stay in the React-free transport layer. The Settings component receives a framework-bound selector hook and a plain callback, so no UI store duplicates transport state; only the two-second success presentation and 500ms dot animation are presentation-local. + +## Testing + +Connection and Gateway tests pin the two-second heartbeat and Pong deadline, exponential retry limits and logs, browser offline suspension and online reset, manual sequence reset, one socket replacement per requested attempt, state deduplication, listener isolation, and disposal. Component tests pin healthy-state absence, hover/action copy, the independent dot animation, click behavior, and the two-second success state. The assembled Web test drives browser offline/online transitions, failed WebSocket attempts, stable indicator geometry, manual recovery, and the success confirmation through the shipped application. diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md new file mode 100644 index 0000000000..c1172c5300 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Web 连接恢复控件 + +Status: implemented + +[English](2026-08-28-web-connection-recovery-control.md) | 中文 + +## Problem + +Web Client 会在故障后自动重建 Remote event generation 与物理 WebSocket,但页面既不显示断联,也不提供用户恢复操作。logical generation 与 physical socket 的重试循环还可能错位:`retry #N` 消息可能描述另一个 logical generation,而浏览器仍在等待同一个物理连接候选。Host 每 30 秒才发送一次空闲 WebSocket Ping,用户在恢复 Host 或网络后也无法主动要求一次全新尝试。 + +## Decision + +Host 默认通过既有且经过校验的 `websocketHeartbeatIntervalMs` 配置,每 2 秒发送一次 WebSocket Ping 控制帧。每次 Ping 前,它把 socket 标记为等待 Pong;到下一间隔仍未收到 Pong 的 socket 会被终止。`ConnectionController` 是唯一的 retry 调度器。在线状态下的传输失败进入带抖动的指数退避:上限从 500ms 开始,依次翻倍为 1s、2s、4s、8s,最终封顶 10s;实际延迟是上限的 50%–100%。10s 档的 retry 仍失败后,自动恢复结束并发布 `disconnected`。每次物理 retry 都发布 `connecting`、写一条 `retry #N` warning、要求 Gateway mux 恰好一次替换候选或活动 socket,再重开内部 `$events` stream。 + +Client Connection 服务暴露 identity 稳定的 `ctx.connection.state` observable 与 `ctx.connection.reconnect()`。snapshot 在首次连接结果前为 undefined,此后为 `disconnected`、`connecting` 或 `connected`;等价状态不触发通知。手动重连会中断当前 generation 或重试等待、重置 attempt 序号,并通过与自动恢复相同的物理和逻辑路径立即开始 retry 1。浏览器的 `offline` 事件会立即中断活动连接工作、发布 `disconnected` 并暂停自动 retry;下一次 `online` 转换会发布 `connecting`、重置 attempt 序号,并从 500ms 退避档重新开始;重复事件不会创建另一条循环。Host 是否可达由新的 `$events` ready 帧证明,而不是由 `navigator.onLine` 证明。替换 generation 建立后,各 logical stream 仍自行持有 baseline、cursor 与 replay 语义。 + +[Web Client 架构](../architecture/2026-07-19-gui-web-client-architecture.zh.md)、[Remote 事件投递](../architecture/2026-08-10-remote-event-delivery.zh.md)和[会话事件传输](../architecture/2026-08-18-session-history-and-event-transport.zh.md)继续持有各自更宽的所有权决策;本笔记只取代其中原有的重试时序。 + +Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。 + +## Alternatives considered + +**固定每 2 秒重试且不进入终态。**不采用,因为长时间故障会持续产生连接流量。保留的指数策略先快速重试,再逐步降低频率,并在 10s 档失败后留下稳定的恢复操作。 + +**在视口顶部渲染全宽 `ConnectionBanner`。**不采用,因为状态应放在用户指定的恢复操作旁,全局覆盖层还会占用无关页面界面框架。该原语是内联 `ConnectionIndicator`;首次标签发布前不存在 `ConnectionBanner` 兼容导出。 + +**通过 `ctx.remote.$connection` 暴露生命周期控制。**不采用,因为 retry 状态与命令属于 Connection 服务,而不是 Remote 方法 namespace。直接使用 `ctx.connection` 仍是例外;本指示器本身负责控制重连,因此符合该例外。 + +**仅在用户点击时重试。**不采用,因为用户没有观察页面时仍必须自动恢复;按钮会重置退避并跳过当前等待。 + +## Consequences + +空闲浏览器连接的心跳流量会高于原默认值;长时间故障则在封顶档 retry 失败后停止产生连接尝试。部署仍可覆盖 Host Ping 间隔。Gateway mux 不拥有第二个 retry timer,因此每条 `retry #N` warning 都对应一次由 Controller 请求的物理尝试。 + +手动重连会刻意中断共享物理 socket 的全部 logical Remote stream。它们既有的 generation supervisor 会通过新 baseline 或 cursor 恢复状态;单向通知仍不重放。 + +连接状态与浏览器网络输入都位于 React-free 传输层。Settings 组件只接收框架绑定的 selector hook 与普通回调,因此没有 UI store 复制传输状态;只有 2 秒成功提示和 500ms 点动画属于展示层本地状态。 + +## Testing + +Connection 与 Gateway 测试固定 2 秒心跳及 Pong deadline、指数 retry 上限与日志、浏览器离线暂停和在线重置、手动重置序列、每次请求只替换一个 socket、状态去重、listener 隔离与 dispose。组件测试固定健康状态下不显示、悬浮与操作文案、独立点动画、点击行为与 2 秒成功状态。组装 Web 测试通过随附浏览器应用驱动浏览器 offline/online 转换、失败的 WebSocket 尝试、稳定的指示器几何、手动恢复与成功确认。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 1a01d0c4f9..625ddd73b2 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -3,7 +3,7 @@ // One tiny recorded turn (text-only) drives the whole spec: the empty-state // hero materializes a real Workspace + Session on first send (the jsdom // workspace-flow suite pins the object-layer state machine over the fixture -// client; THIS spec pins the same flow through HTTP RPC + SSE + the host +// client; THIS spec pins the same flow through HTTP RPC + WebSocket + the host // gateway), reload replays everything from the log (zero further model // calls), and the theme scenario proves the shipped dark palette actually // cascades: attribute -> alias token flip -> painted surface change. No @@ -13,7 +13,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -22,7 +22,9 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' +import { + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -31,6 +33,7 @@ const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') +const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -282,12 +285,128 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it.skipIf(MODE === 'record')('shows automatic and user-requested connection recovery beside Settings', async () => { + const recoveryPage = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, + locale: ZH_BROWSER_LOCALE, + }) + const recoveryTripwire = watchConsole(recoveryPage) + const sockets: WebSocketRoute[] = [] + let rejectConnections = false + await recoveryPage.routeWebSocket('**/api/remote.mux', (route) => { + sockets.push(route) + if (rejectConnections) { + void route.close({ code: 4001, reason: 'connection recovery test' }) + return + } + route.connectToServer() + }) + onTestFailed(() => saveFailureShot(recoveryPage, 'web-e2e-connection-recovery')) + try { + await recoveryPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await recoveryPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => sockets.length).toBe(1) + rejectConnections = true + await recoveryPage.context().setOffline(true) + await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(false) + const offline = recoveryPage.getByRole('button', { + name: '连接异常,点击立即重连', exact: true, + }) + await offline.waitFor({ timeout: 2_000 }) + await recoveryPage.waitForTimeout(750) + expect(sockets).toHaveLength(1) + + await recoveryPage.context().setOffline(false) + await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true) + const connecting = recoveryPage.getByRole('button', { + name: '连接中,点击立即重连', exact: true, + }) + await connecting.waitFor({ timeout: 10_000 }) + expect(await connecting.innerText()).toMatch(/^连接中\.{1,3}$/) + const connectingGeometry = await connectionIndicatorGeometry(connecting) + await connecting.hover() + expect(await connecting.innerText()).toBe('立即重连') + expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) + await recoveryPage.mouse.move(0, 0) + + await expect.poll(() => sockets.length, { timeout: 40_000 }).toBe(7) + const indicator = recoveryPage.getByRole('button', { + name: '连接异常,点击立即重连', exact: true, + }) + await indicator.waitFor({ timeout: 10_000 }) + expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry) + const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE) + const style = await indicator.evaluate((element) => { + const probe = document.createElement('span') + probe.style.color = 'var(--dsw-alias-state-warn-label)' + probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + document.body.append(probe) + const actual = getComputedStyle(element) + const reference = getComputedStyle(probe) + const result = { + background: actual.backgroundColor, + color: actual.color, + referenceBackground: reference.backgroundColor, + referenceColor: reference.color, + } + probe.remove() + return result + }) + expect(style.background).toBe(style.referenceBackground) + expect(style.color).toBe(style.referenceColor) + expect(await indicator.locator('svg').count()).toBe(1) + expect(await indicator.getAttribute('title')).toBeNull() + const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) + await indicator.hover() + expect(await indicator.innerText()).toBe('立即重连') + const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) + expect(hoverBackground).toBe(idleBackground) + await recoveryPage.mouse.down() + await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor)) + .not.toBe(hoverBackground) + rejectConnections = false + await recoveryPage.mouse.up() + + await expect.poll(() => sockets.length).toBe(8) + const recovered = recoveryPage.getByRole('status') + await recovered.waitFor({ timeout: 10_000 }) + expect(await recovered.innerText()).toBe('连接成功') + expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry) + await recovered.waitFor({ state: 'detached', timeout: 5_000 }) + expect(recoveryTripwire.pageErrors).toEqual([]) + expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #[1-6]/i.test(warning))) + .toHaveLength(7) + } finally { + await recoveryPage.close() + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', - 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', + 'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) }) + +async function connectionIndicatorGeometry(locator: ReturnType): Promise<{ + readonly outer: readonly number[] + readonly icon: readonly number[] + readonly label: readonly number[] +}> { + return await locator.evaluate((element) => { + const outer = element.getBoundingClientRect() + const icon = element.children.item(0)?.getBoundingClientRect() + const label = element.children.item(1)?.getBoundingClientRect() + if (icon === undefined || label === undefined) throw new Error('connection indicator children missing') + const rounded = (values: readonly number[]): readonly number[] => values.map(value => Math.round(value * 100) / 100) + return { + outer: rounded([outer.x, outer.y, outer.width, outer.height]), + icon: rounded([icon.x - outer.x, icon.y - outer.y, icon.width, icon.height]), + label: rounded([label.x - outer.x, label.y - outer.y, label.width, label.height]), + } + }) +} diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ad34214f63..9ccc5d22db 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: a48163a2ea9b04c99e2322620e207cc0a5aa3b88 -config-catalog.zh.md: bb662eecf45d72eb605a93663e9177cab10fcc34 +config-catalog.md: 9e2b671ee054af797e9a919920fdd799e8c50e61 +config-catalog.zh.md: df386630eeddefaccd9d1630da95a7116492173c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a48163a2ea..9e2b671ee0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -285,7 +285,7 @@ Requires: `typert` ```ts config-catalog /** Gateway transport configuration. */ export interface Config { - /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bb662eecf4..df386630ee 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -287,7 +287,7 @@ export interface Config { ```ts config-catalog /** Gateway transport configuration. */ export interface Config { - /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number } ``` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 04e07d0c19..7112bfb506 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: 5c8d6a4f4c9eff9f2ddee548ecd9003e7a5eda8b -module-graph.zh.md: e507a11e6e3e1c220c7947a76cda16645e79512f +module-graph.md: 7c4ccbf6841070d37116641c1404a70cbb598769 +module-graph.zh.md: 4581a5bdda1e75a9678a731ab109d3b3c71b7434 diff --git a/docs/module-graph.md b/docs/module-graph.md index 5c8d6a4f4c..7c4ccbf684 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1506,6 +1506,7 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule 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 @@ -1965,7 +1966,7 @@ flowchart TD | [`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-schedule`](../packages/client/ui-schedule) | `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), [`schedule`](../packages/schedule/schedule) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`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-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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e507a11e6e..4581a5bdda 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1508,6 +1508,7 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule 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 @@ -1967,7 +1968,7 @@ flowchart TD | [`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-schedule`](../packages/client/ui-schedule) | `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), [`schedule`](../packages/schedule/schedule) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`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-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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index dfaa6be5bf..a88823340e 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: 509f1108a8f940b93b9555399ec59c26325fe4b8 -README.zh.md: 116969c9f04d7791475ff63d8b4602e96c2aaa5b +README.md: 8cbb8048ca2e63efb7ea65cb2b92b06f78455f6a +README.zh.md: a15ad89e15e2725d9802b1676f3ddd1bbd54b03f diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 509f1108a8..8cbb8048ca 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. @@ -49,7 +49,9 @@ Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ o `ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under bounded jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. + +`ctx.remote` exposes no Connection lifecycle control. A consumer whose responsibility includes recovery reads `ctx.connection.state` and calls `ctx.connection.reconnect()` directly; ordinary Remote consumers stay on generated namespaces and `$stream()`. The [connection recovery decision](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md) owns this exception. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. @@ -72,7 +74,7 @@ No direct effect; invoked business Services own any model-visible result. - `$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. -- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects. +- `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 116969c9f0..a15ad89e15 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验、每 Client 队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 @@ -49,7 +49,9 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按有界且带抖动的指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 + +`ctx.remote` 不暴露 Connection 生命周期控制。只有职责包含恢复的消费方才直接读取 `ctx.connection.state` 并调用 `ctx.connection.reconnect()`;普通 Remote 消费方仍只使用生成的 namespace 与 `$stream()`。[连接恢复决策](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md)规定这项例外。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 @@ -72,7 +74,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source - `$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 生命周期。 -- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。 +- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 07a76d685f..2ad8c0225a 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -218,7 +218,7 @@ afterEach(async () => { describe('Typert Remote streams', () => { it('validates the WebSocket heartbeat timer range', () => { - expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 }) + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000 }) expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index d0af7669e9..5b4cbdf3b0 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -308,6 +308,7 @@ async function benchFiber( readonly ctx: Context readonly client: Fiber readonly generation: GenerationHarness + readonly start: ReturnType> }> { const ctx = new Context() await ctx.plugin(TypertRegistry) @@ -315,14 +316,15 @@ async function benchFiber( ? { call } : { call, open } const generation = new GenerationHarness() + const start = vi.fn(() => ({ stop: () => {} })) ctx.provide('connection', { rpc, registerGenerationSource: generation.register, - start: () => ({ stop: () => {} }), + start, } as unknown as ConnectionHandle) const client = ctx.plugin({ inject, apply }) await client - return { ctx, client, generation } + return { ctx, client, generation, start } } async function *unexpectedInProcessStream(): AsyncGenerator { @@ -404,7 +406,10 @@ function deferredReadiness(): { return { promise, resolve, reject } } -async function loaderReadinessBench(readiness: Promise): Promise<{ +async function loaderReadinessBench( + readiness: Promise, + carrier: 'in-process' | 'web' = 'in-process', +): Promise<{ readonly client: Fiber readonly start: ReturnType> readonly stop: ReturnType void>> @@ -414,11 +419,9 @@ async function loaderReadinessBench(readiness: Promise): Promise<{ const generation = new GenerationHarness() const stop = vi.fn<() => void>() const start = vi.fn(() => ({ stop })) + const call = vi.fn() ctx.provide('connection', { - rpc: { - call: vi.fn(), - open: () => unexpectedInProcessStream(), - }, + rpc: carrier === 'web' ? { call } : { call, open: () => unexpectedInProcessStream() }, registerGenerationSource: generation.register, start, } as unknown as ConnectionHandle) @@ -584,6 +587,35 @@ describe('Client Remote transport readiness', () => { expect(remote.$host).toBe(afterReady) }) + it('forwards each connection retry to the browser WebSocket owner', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const { client, start } = await benchFiber( + vi.fn(), + 'web', + ) + try { + expect(FakeWebSocket.sockets).toHaveLength(1) + start.mock.calls[0]![0].onReconnectRequested?.() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + } finally { + await client.dispose() + } + }) + }) + + it('does not replace an in-process carrier when Connection retries', async () => { + const { client, start } = await benchFiber( + vi.fn(), + 'in-process', + ) + try { + expect(() => { start.mock.calls[0]![0].onReconnectRequested?.() }).not.toThrow() + } finally { + await client.dispose() + } + }) + it('starts after Loader settlement and stops the owned loop on disposal', async () => { const readiness = deferredReadiness() const { client, start, stop } = await loaderReadinessBench(readiness.promise) @@ -596,6 +628,20 @@ describe('Client Remote transport readiness', () => { expect(stop).toHaveBeenCalledTimes(1) }) + it('starts a fresh WebSocket attempt when Loader settles after the eager attempt failed', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const readiness = deferredReadiness() + const { client, start } = await loaderReadinessBench(readiness.promise, 'web') + expect(FakeWebSocket.sockets).toHaveLength(1) + FakeWebSocket.sockets[0]!.fail() + readiness.resolve() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(start).toHaveBeenCalledOnce() + await client.dispose() + }) + }) + it('does not start when disposal wins the Loader-settlement race', async () => { const readiness = deferredReadiness() const { client, start, stop } = await loaderReadinessBench(readiness.promise) @@ -2237,62 +2283,138 @@ describe('Client Typert API', () => { }) describe('Remote stream client carrier lifecycle', () => { - it('connects without a logical stream, reconnects after failures, and stops permanently', async () => { + it('requires the transport owner to start the physical carrier', async () => { + const client = new RemoteStreamMuxClient() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client not started') + await client.close() + }) + + it('connects without a logical stream, waits for owner-driven retries, and stops permanently', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const client = new RemoteStreamMuxClient() - client.start() - client.start() - expect(FakeWebSocket.sockets).toHaveLength(1) + const client = new RemoteStreamMuxClient() + client.start() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(1) - const failed = FakeWebSocket.sockets[0]! - failed.fail() - await vi.advanceTimersByTimeAsync(500) - expect(FakeWebSocket.sockets).toHaveLength(2) + const failed = FakeWebSocket.sockets[0]! + failed.fail() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(1) - const connected = FakeWebSocket.sockets[1]! - connected.open() - await vi.advanceTimersByTimeAsync(0) - expect(connected.sent).toEqual([]) - connected.fail() - await vi.advanceTimersByTimeAsync(500) - expect(FakeWebSocket.sockets).toHaveLength(3) + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const connected = FakeWebSocket.sockets[1]! + connected.open() + await Promise.resolve() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(2) + expect(connected.sent).toEqual([]) + connected.fail() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(2) - const replacement = FakeWebSocket.sockets[2]! - replacement.open() - replacement.drop() - await vi.advanceTimersByTimeAsync(500) - expect(FakeWebSocket.sockets).toHaveLength(4) + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(3) }) + const final = FakeWebSocket.sockets[2]! + final.open() + await client.close() + await client.close() + client.start() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') - const final = FakeWebSocket.sockets[3]! - final.open() - await vi.advanceTimersByTimeAsync(0) - await client.close() - await client.close() - client.start() - await expect(client.open('feed/follow', {}, new AbortController().signal) - [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') - await vi.advanceTimersByTimeAsync(20_000) + expect(FakeWebSocket.sockets).toHaveLength(3) + expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) - expect(FakeWebSocket.sockets).toHaveLength(4) - expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) - expect(warn).toHaveBeenCalledTimes(3) + const stopping = new RemoteStreamMuxClient() + stopping.start() + const racing = FakeWebSocket.sockets[3]! + racing.open() + racing.drop() + await stopping.close() + expect(FakeWebSocket.sockets).toHaveLength(4) + }) + }) - const stopping = new RemoteStreamMuxClient() - stopping.start() - const racing = FakeWebSocket.sockets[4]! - racing.open() - racing.drop() - await stopping.close() - await vi.advanceTimersByTimeAsync(20_000) - expect(FakeWebSocket.sockets).toHaveLength(5) - } finally { - warn.mockRestore() - vi.useRealTimers() - } + it('mints a new wire stream id when the same endpoint opens on a replacement socket', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const first = client.open('feed/follow', { label: 'same' }, new AbortController().signal) + [Symbol.asyncIterator]() + const firstPending = first.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const firstSocket = FakeWebSocket.sockets[0]! + const firstOpen = JSON.parse(firstSocket.sent[0]!) as { streamId: string } + firstSocket.receive({ type: 'end', streamId: firstOpen.streamId }) + await expect(firstPending).resolves.toEqual({ done: true, value: undefined }) + + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const second = client.open('feed/follow', { label: 'same' }, new AbortController().signal) + [Symbol.asyncIterator]() + const secondPending = second.next() + const secondSocket = FakeWebSocket.sockets[1]! + await vi.waitFor(() => { expect(secondSocket.sent).toHaveLength(1) }) + const secondOpen = JSON.parse(secondSocket.sent[0]!) as { streamId: string } + expect(secondOpen.streamId).not.toBe(firstOpen.streamId) + secondSocket.receive({ type: 'end', streamId: secondOpen.streamId }) + await expect(secondPending).resolves.toEqual({ done: true, value: undefined }) + await client.close() + }) + }) + + it('replaces an in-flight candidate and an open socket on reconnect', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + client.start() + const candidate = FakeWebSocket.sockets[0]! + + client.reconnect() + const replacementPending = client.open( + 'feed/follow', + { label: 'replacement' }, + new AbortController().signal, + )[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(candidate.closedWith).toContainEqual({}) + const connected = FakeWebSocket.sockets[1]! + connected.open() + await vi.waitFor(() => { expect(connected.sent).toHaveLength(1) }) + const opened = JSON.parse(connected.sent[0]!) as { streamId: string } + connected.receive({ type: 'end', streamId: opened.streamId }) + await expect(replacementPending).resolves.toEqual({ done: true, value: undefined }) + + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(3) }) + expect(connected.closedWith).toContainEqual({ code: 4000, reason: 'reconnect requested' }) + + await client.close() + client.reconnect() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(3) + }) + }) + + it('coalesces repeated candidate replacements and drops one queued after close', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + client.start() + const first = FakeWebSocket.sockets[0]! + + client.reconnect() + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(first.closedWith).toContainEqual({}) + + client.reconnect() + await client.close() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(2) }) }) @@ -2300,6 +2422,7 @@ describe('Remote stream client carrier lifecycle', () => { await withFakeWebSocket(undefined, async () => { FakeWebSocket.autoOpen = false const client = new RemoteStreamMuxClient() + client.start() const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) [Symbol.asyncIterator]() const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal) @@ -2321,50 +2444,50 @@ describe('Remote stream client carrier lifecycle', () => { }) }) - it('keeps waiters across failed attempts and contains waiter cancellation', async () => { + it('fails waiters with one socket attempt and lets the owner start the next attempt', async () => { await withFakeWebSocket('null', async () => { FakeWebSocket.autoOpen = false - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const closedClient = new RemoteStreamMuxClient() - const closed = closedClient.open('feed/follow', {}, new AbortController().signal) - [Symbol.asyncIterator]().next() - FakeWebSocket.sockets[0]!.drop() - await vi.advanceTimersByTimeAsync(500) + const closedClient = new RemoteStreamMuxClient() + closedClient.start() + const closed = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[0]!.drop() + await expect(closed).rejects.toThrow('Remote stream WebSocket closed before opening') - const replacement = FakeWebSocket.sockets[1]! - replacement.open() - await vi.advanceTimersByTimeAsync(0) - const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } - replacement.receive({ type: 'end', streamId }) - await expect(closed).resolves.toEqual({ done: true, value: undefined }) - await closedClient.close() + closedClient.reconnect() + const replacementStream = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const replacement = FakeWebSocket.sockets[1]! + replacement.open() + await vi.waitFor(() => { expect(replacement.sent).toHaveLength(1) }) + const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } + replacement.receive({ type: 'end', streamId }) + await expect(replacementStream).resolves.toEqual({ done: true, value: undefined }) + await closedClient.close() - const disposedClient = new RemoteStreamMuxClient() - const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) - [Symbol.asyncIterator]().next() - FakeWebSocket.sockets[2]!.fail() - await disposedClient.close() - await expect(disposed).rejects.toThrow('Remote stream client disposed') + const disposedClient = new RemoteStreamMuxClient() + disposedClient.start() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await disposedClient.close() + await expect(disposed).rejects.toThrow('Remote stream client disposed') - const abortedClient = new RemoteStreamMuxClient() - const abort = new AbortController() - const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() - abort.abort('cancelled while connecting') - await expect(aborted).rejects.toBe('cancelled while connecting') - await abortedClient.close() - expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') - } finally { - warn.mockRestore() - vi.useRealTimers() - } + const abortedClient = new RemoteStreamMuxClient() + abortedClient.start() + const abort = new AbortController() + const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + abort.abort('cancelled while connecting') + await expect(aborted).rejects.toBe('cancelled while connecting') + await abortedClient.close() + expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') }) }) it('fails active streams on an invalid frame and ignores later frames', async () => { await withFakeWebSocket('https://harness.example', async () => { const client = new RemoteStreamMuxClient() + client.start() const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() const pending = stream.next() await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) @@ -2386,6 +2509,7 @@ describe('Remote stream client carrier lifecycle', () => { it('completes a stream and drops a frame racing with cancellation', async () => { await withFakeWebSocket('https://harness.example', async () => { const client = new RemoteStreamMuxClient() + client.start() const completed = client.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]() const completedPending = completed.next() @@ -2410,6 +2534,7 @@ describe('Remote stream client carrier lifecycle', () => { it('contains non-Error cancellation reasons and late socket close events', async () => { await withFakeWebSocket('http://harness.example', async () => { const cancelledClient = new RemoteStreamMuxClient() + cancelledClient.start() const abort = new AbortController() const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) @@ -2419,6 +2544,7 @@ describe('Remote stream client carrier lifecycle', () => { FakeWebSocket.dispatchClose = false const disposedClient = new RemoteStreamMuxClient() + disposedClient.start() const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) }) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 2cc4c99f8c..e73c76f8c0 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -50,6 +50,18 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) + it('terminates a socket that does not answer the previous heartbeat', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + serverSocket.removeAllListeners('pong') + const terminated = vi.spyOn(serverSocket, 'terminate') + const closed = once(client, 'close') + + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + await closed + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -193,7 +205,7 @@ const mapFailure: RemoteStreamFailureMapper = error => ({ details: {}, }) -async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise { +async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): Promise { const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) const http = createServer() http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 2f74ffe9a1..07f88b1a4f 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -57,9 +57,11 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { return () => { generationListeners.delete(listener) } }, }, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, + reconnect: () => {}, registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index fdfbf23105..b050b7c22c 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -199,9 +199,11 @@ function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { const connection: ConnectionHandle = { isLoopback: true, generation: AVAILABLE_CONNECTION.generation, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, + reconnect: () => {}, registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 6be2c4aa21..528a378161 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: af757608aa7face6854aaf9d103654f974b51ed4 -README.zh.md: fef7248abe1e19595b90c43a6a1b9abf0aafa88a +README.md: ee0566bf811a0bad32f7d59b4c8849e26efea613 +README.zh.md: 48425a428247b7749bf0dd75f592b5f7e4088ef2 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index af757608aa..ee0566bf81 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` reconnects with backoff. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. ## Table of Contents @@ -43,7 +43,7 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. -An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws the generation, publishes `reconnecting`, and reopens `$events` after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s. It logs each attempt, asks Gateway to replace the physical WebSocket, and reopens `$events`; failure in the 10s tier publishes terminal `disconnected`. `ctx.connection.reconnect()` interrupts active work, resets the sequence, and starts retry 1 immediately. Browser `offline` aborts active work, publishes `disconnected`, and suspends automatic attempts; the next `online` transition resets the sequence and starts at the 500ms tier. A ready item publishes `connected`. The Gateway mux performs one physical connection attempt per request rather than running an independent retry schedule. The [connection recovery decision](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md) owns the cadence and manual recovery behavior. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index fef7248abe..48425a4282 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 退避重连。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 ## 目录 @@ -43,7 +43,7 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 -`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 generation、发布 `reconnecting`,并在退避后重开 `$events`。Gateway mux 自己负责重建底层 WebSocket;Connection generation 负责重开 logical stream 并建立下一次 baseline 起点。 +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试。它记录每次尝试、要求 Gateway 替换物理 WebSocket,再重开 `$events`;10s 档失败后发布终态 `disconnected`。`ctx.connection.reconnect()` 会中断活动工作、重置序列,并立即开始 retry 1。浏览器 `offline` 会中断活动工作、发布 `disconnected` 并暂停自动尝试;下一次 `online` 转换会重置序列并从 500ms 档开始。ready 项会发布 `connected`。Gateway mux 每次收到请求只做一次物理连接尝试,不再运行另一套重试调度。[连接恢复决策](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md)规定重试节奏和手动恢复行为。 ## 模型体验 diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index e317c78f0d..031204dc92 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -9,6 +9,7 @@ import { type ClientTransportHooks, type ConnectionGenerationSource, type ConnectionHandle, + type ConnectionState, } from '../src/client/index.ts' type Win = { @@ -19,8 +20,19 @@ type Win = { afterEach(() => { delete (globalThis as Win).location delete (globalThis as Win).__DSH_TRANSPORT__ + vi.unstubAllGlobals() + vi.useRealTimers() }) +class BrowserNetworkProbe extends EventTarget { + readonly navigator = { onLine: true } + + setOnline(online: boolean): void { + this.navigator.onLine = online + this.dispatchEvent(new Event(online ? 'online' : 'offline')) + } +} + class GenerationProbe { private readonly active = new Set<() => void>() @@ -133,6 +145,23 @@ describe('connection client apply', () => { errorSpy.mockRestore() }) + it('does not notify state subscribers when a pre-ready loop stops', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + handle.registerGenerationSource(signal => new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + })) + const listener = vi.fn() + const unsubscribe = handle.state.subscribe(listener) + const loop = handle.start({}) + + loop.stop() + + expect(handle.state.getSnapshot()).toBeUndefined() + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) + it('allows a replacement owner and ignores the previous owner handle', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() @@ -156,6 +185,94 @@ describe('connection client apply', () => { generation.end() }) + it('lets the connection service force only its current owner to reconnect', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) + const requested = vi.fn() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loop = handle.start({ onReconnectRequested: requested }, { + backoffBaseMs: 60_000, + backoffFactor: 2, + backoffMaxMs: 120_000, + generationReadyTimeoutMs: 500, + }) + try { + await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(1) }) + handle.reconnect() + await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(2) }) + expect(requested).toHaveBeenCalledOnce() + loop.stop() + handle.reconnect() + expect(requested).toHaveBeenCalledOnce() + } finally { + loop.stop() + warnSpy.mockRestore() + } + }) + + it('ignores a non-browser window shim without navigator state', async () => { + vi.stubGlobal('window', new EventTarget()) + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) + const loop = handle.start({}) + try { + await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') }) + } finally { + loop.stop() + } + }) + + it('feeds browser offline and online events into the owned retry loop', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const browser = new BrowserNetworkProbe() + vi.stubGlobal('window', browser) + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + let calls = 0 + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + calls++ + ready({ home: '/h' }) + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + handle.registerGenerationSource(source) + const states: Array = [] + const unsubscribe = handle.state.subscribe(() => { states.push(handle.state.getSnapshot()) }) + const loop = handle.start({}, { + backoffBaseMs: 100, + backoffFactor: 2, + backoffMaxMs: 1_000, + generationReadyTimeoutMs: 500, + }) + try { + await vi.advanceTimersByTimeAsync(0) + expect(handle.state.getSnapshot()).toBe('connected') + expect(calls).toBe(1) + + browser.setOnline(false) + expect(handle.state.getSnapshot()).toBe('disconnected') + await vi.advanceTimersByTimeAsync(10_000) + expect(calls).toBe(1) + + browser.setOnline(true) + expect(handle.state.getSnapshot()).toBe('connecting') + await vi.advanceTimersByTimeAsync(49) + expect(calls).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(calls).toBe(2) + expect(handle.state.getSnapshot()).toBe('connected') + expect(states).toEqual(['connected', 'disconnected', 'connecting', 'connected']) + } finally { + unsubscribe() + loop.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + } + }) + it('does not announce a generation synchronously stopped by a generation subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() @@ -180,7 +297,7 @@ describe('connection client apply', () => { } }) - it('retracts the generation while reconnecting and publishes the next generation', async () => { + it('retracts the generation while connecting and publishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) @@ -192,11 +309,11 @@ describe('connection client apply', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { - if (state === 'reconnecting') { + if (state === 'connecting') { reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home) } }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.host.home).toBe('/h') @@ -213,7 +330,45 @@ describe('connection client apply', () => { } }) - it('does not announce reconnecting after a generation subscriber stops the loop', async () => { + it('publishes connection state directly on the service and isolates subscribers', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const generation = installGeneration(handle) + const snapshots: Array = [] + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const unsubscribe = handle.state.subscribe(() => { snapshots.push(handle.state.getSnapshot()) }) + const stopThrowing = handle.state.subscribe(() => { throw new Error('state subscriber failed') }) + expect(handle.state.getSnapshot()).toBeUndefined() + + const loop = handle.start({}, { + backoffBaseMs: 10, + backoffFactor: 2, + backoffMaxMs: 80, + generationReadyTimeoutMs: 500, + }) + try { + await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') }) + const connected = handle.state.getSnapshot() + expect(handle.state.getSnapshot()).toBe(connected) + generation.end() + await vi.waitFor(() => { + expect(snapshots).toEqual([ + 'connected', + 'connecting', + 'connected', + ]) + }) + expect(errorSpy).toHaveBeenCalledWith('[connection] state listener threw:', expect.any(Error)) + } finally { + unsubscribe() + stopThrowing() + loop.stop() + errorSpy.mockRestore() + } + expect(handle.state.getSnapshot()).toBeUndefined() + }) + + it('does not announce disconnection after a generation subscriber stops the loop', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) @@ -224,11 +379,11 @@ describe('connection client apply', () => { stoppedOnRetraction = true owner.loop.stop() }) - const states: string[] = [] + const states: ConnectionState[] = [] const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { states.push(state) }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 }) owner.loop = loop try { await vi.waitFor(() => { diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 94038abf8e..3b9432995f 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -5,7 +5,7 @@ import type { ConnectionGenerationSource, ConnectionState } from '../src/client/ import { ConnectionController } from '../src/client/connection.ts' import { FakeGenerationSource } from './fake-generation.client.ts' -const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } +const FAST = { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { it('announces connected with the Host facts from generation readiness', async () => { @@ -42,6 +42,404 @@ describe('connection lifecycle', () => { expect(source.activeCount).toBe(0) }) + it('uses jittered exponential backoff and stops after the capped retry fails', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reconnectRequested = vi.fn() + let calls = 0 + const states: ConnectionState[] = [] + const source: ConnectionGenerationSource = () => { + calls++ + return Promise.reject(new Error('offline')) + } + const controller = new ConnectionController(source, { + onReconnectRequested: reconnectRequested, + onStateChange: state => states.push(state), + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + expect(states).toEqual(['connecting']) + + for (const [attempt, delay] of [250, 500, 1_000, 2_000, 4_000, 5_000].entries()) { + await vi.advanceTimersByTimeAsync(delay) + expect(calls).toBe(attempt + 2) + } + + expect(reconnectRequested).toHaveBeenCalledTimes(6) + expect(warnSpy).toHaveBeenCalledTimes(6) + expect(warnSpy).toHaveBeenLastCalledWith('[connection] connection lost, retry #6') + expect(states.at(-1)).toBe('disconnected') + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(7) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('treats a non-growing backoff as one terminal retry tier', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const states: ConnectionState[] = [] + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: state => states.push(state), + }, { + backoffBaseMs: 10, + backoffFactor: 1, + backoffMaxMs: 80, + generationReadyTimeoutMs: 500, + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(5) + expect(calls).toBe(2) + expect(states).toEqual(['connecting', 'disconnected']) + await vi.advanceTimersByTimeAsync(1_000) + expect(calls).toBe(2) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('interrupts the retry delay when a reconnect is requested', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reconnectRequested = vi.fn() + let calls = 0 + const source: ConnectionGenerationSource = (signal, ready) => { + calls++ + if (calls === 1) return Promise.reject(new Error('offline')) + ready({ home: '/h' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { onReconnectRequested: reconnectRequested }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + controller.reconnect() + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(2) + expect(reconnectRequested).toHaveBeenCalledOnce() + } finally { + controller.stop() + controller.reconnect() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('pauses retries while offline and restarts the base delay after each recovery', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const states: ConnectionState[] = [] + let calls = 0 + let active = 0 + let maxActive = 0 + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + calls++ + active++ + maxActive = Math.max(maxActive, active) + ready({ home: '/h' }) + signal.addEventListener('abort', () => { + active-- + resolve() + }, { once: true }) + }) + const controller = new ConnectionController(source, { + onStateChange: state => states.push(state), + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + expect(states).toEqual(['connected']) + + controller.setNetworkAvailable(false) + controller.setNetworkAvailable(false) + expect(states.at(-1)).toBe('disconnected') + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + expect(active).toBe(0) + + controller.setNetworkAvailable(true) + controller.setNetworkAvailable(true) + expect(states.at(-1)).toBe('connecting') + await vi.advanceTimersByTimeAsync(125) + controller.setNetworkAvailable(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + + controller.setNetworkAvailable(true) + await vi.advanceTimersByTimeAsync(249) + expect(calls).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(calls).toBe(2) + expect(active).toBe(1) + expect(maxActive).toBe(1) + expect(states).toEqual([ + 'connected', + 'disconnected', + 'connecting', + 'disconnected', + 'connecting', + 'connected', + ]) + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy).toHaveBeenCalledWith('[connection] connection lost, retry #1') + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('allows one manual attempt while offline without starting automatic retries', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const states: ConnectionState[] = [] + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: state => states.push(state), + }) + controller.setNetworkAvailable(false) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(states).toEqual(['disconnected']) + expect(calls).toBe(0) + + controller.reconnect() + expect(states.at(-1)).toBe('connecting') + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + expect(states.at(-1)).toBe('disconnected') + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + } finally { + controller.stop() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('does not lose a reconnect requested synchronously from the terminal state sink', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + let restart = true + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: (state) => { + if (state !== 'disconnected' || !restart) return + restart = false + controller.reconnect() + }, + }, { + backoffBaseMs: 10, + backoffFactor: 2, + backoffMaxMs: 10, + generationReadyTimeoutMs: 500, + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(5) + expect(calls).toBe(3) + expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([ + '[connection] connection lost, retry #1', + '[connection] connection lost, retry #1', + ]) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it.each([ + { + label: 'manual reconnect', + stopState: 'connecting' as const, + interrupt: (controller: ConnectionController) => { controller.reconnect() }, + }, + { + label: 'browser going offline', + stopState: 'disconnected' as const, + interrupt: (controller: ConnectionController) => { controller.setNetworkAvailable(false) }, + }, + ])('honors a synchronous stop from the $label state sink', async ({ stopState, interrupt }) => { + const source = new FakeGenerationSource() + const controller = new ConnectionController(source.source, { + onStateChange: (state) => { + if (state === stopState) controller.stop() + }, + }, FAST) + controller.start() + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) + interrupt(controller) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) + }) + + it('stops when the physical-reconnect sink disposes the controller', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + const reconnectRequested = vi.fn() + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onReconnectRequested: () => { + reconnectRequested() + controller.stop() + }, + }, FAST) + controller.start() + try { + await vi.advanceTimersByTimeAsync(5) + expect(calls).toBe(1) + expect(reconnectRequested).toHaveBeenCalledOnce() + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('stops before opening a retry when the connecting state sink disposes the controller', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: (state) => { + if (state === 'connecting') controller.stop() + }, + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + expect(warnSpy).not.toHaveBeenCalled() + } finally { + controller.stop() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('restarts an active retry immediately and resets its attempt number', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reconnectRequested = vi.fn() + const states: ConnectionState[] = [] + let calls = 0 + const source: ConnectionGenerationSource = (signal) => { + calls++ + if (calls <= 2) return Promise.reject(new Error('offline')) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { + onReconnectRequested: reconnectRequested, + onStateChange: state => states.push(state), + }, FAST) + controller.start() + try { + await vi.advanceTimersByTimeAsync(20) + expect(calls).toBe(3) + expect(states.at(-1)).toBe('connecting') + + controller.reconnect() + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(4) + expect(states.at(-1)).toBe('connecting') + expect(reconnectRequested).toHaveBeenCalledTimes(3) + expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([ + '[connection] connection lost, retry #1', + '[connection] connection lost, retry #2', + '[connection] connection lost, retry #1', + ]) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('stops while an automatic retry delay is pending', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + controller.stop() + await vi.advanceTimersByTimeAsync(2_000) + expect(calls).toBe(1) + } finally { + controller.stop() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('replaces an active generation immediately when reconnect is requested', async () => { + const source = new FakeGenerationSource() + const reconnectRequested = vi.fn() + let connected = 0 + const controller = new ConnectionController(source.source, { + onConnected: () => { connected++ }, + onReconnectRequested: reconnectRequested, + }, { backoffBaseMs: 60_000, backoffFactor: 2, backoffMaxMs: 120_000, generationReadyTimeoutMs: 500 }) + controller.start() + try { + await vi.waitFor(() => { expect(connected).toBe(1) }) + controller.reconnect() + await vi.waitFor(() => { expect(connected).toBe(2) }) + expect(reconnectRequested).toHaveBeenCalledOnce() + expect(source.activeCount).toBe(1) + } finally { + controller.stop() + } + }) + it('isolates a connected sink exception from the generation', async () => { const source = new FakeGenerationSource() let connected = 0 @@ -133,7 +531,7 @@ describe('connection lifecycle', () => { source.holdReady = false source.end() await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) + expect(states).toEqual(['connecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() @@ -182,7 +580,8 @@ describe('connection lifecycle', () => { ) controller.start() try { - await vi.waitFor(() => { expect(source.activeCount).toBeGreaterThan(0) }) + await Promise.resolve() + expect(source.activeCount).toBe(1) await new Promise(resolve => setTimeout(resolve, 45)) expect(connected).toBe(0) } finally { @@ -191,7 +590,7 @@ describe('connection lifecycle', () => { } }) - it('emits deduplicated connected/reconnecting state transitions', async () => { + it('emits the disconnected, retry-attempt, and connected transitions', async () => { const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 @@ -206,7 +605,7 @@ describe('connection lifecycle', () => { expect(states).toEqual(['connected']) source.fail(new Error('torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) - expect(states).toEqual(['connected', 'reconnecting', 'connected']) + expect(states).toEqual(['connected', 'connecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() @@ -231,7 +630,7 @@ describe('connection lifecycle', () => { expect(connected).toBe(0) }) - it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { + it('keeps one connecting state across consecutive retry attempts', async () => { let sourceCalls = 0 const states: ConnectionState[] = [] let connected = 0 @@ -252,7 +651,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(sourceCalls).toBe(3) }) await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) + expect(states).toEqual(['connecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() diff --git a/packages/client/connection/tests/generation.client.spec.ts b/packages/client/connection/tests/generation.client.spec.ts index 0deace5dba..d2b365de4b 100644 --- a/packages/client/connection/tests/generation.client.spec.ts +++ b/packages/client/connection/tests/generation.client.spec.ts @@ -46,8 +46,8 @@ describe('Connection generation facts', () => { }) const loop = connection.start({}, { backoffBaseMs: 1, - backoffFactor: 1, - backoffMaxMs: 1, + backoffFactor: 2, + backoffMaxMs: 8, generationReadyTimeoutMs: 100, }) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index bb4347c085..70ce31598c 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -34,7 +34,6 @@ export const en = { 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', - 'connection.reconnecting': 'Connection lost; reconnecting…', 'json.collapseNode': 'Collapse JSON node', 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index d5b9a45cfd..30cee308d5 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -32,7 +32,6 @@ export const zh = { 'unknown': '未知', 'none': '无', 'truncated': '已截断', - 'connection.reconnecting': '连接已断开,正在重连…', 'json.collapseNode': '收起 JSON 节点', 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d672fb1544..d48604ea73 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: c88f101eee2e6988d06ee75f564ca4aeffd5efe6 -README.zh.md: 0af43e4831fc7ca90f113705dc33d5e3411929b6 +README.md: 42c1110e8735dd2191c8b7a2e4dcc1f2b9b938bc +README.zh.md: 9f3c06cacebb7a596204dbe1c8e00a549ce1fcae diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c88f101eee..42c1110e87 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-primitives` is the web client's shared React component library: every feature plugin composes its UI from these atoms, and nothing here depends on Cordis or the slot system. It provides the control set (buttons, pills, inputs, menus, modals, toast banners, disclosure rows, hover cards, connection banners), the icon glyphs and brand marks, positioning hooks for anchored overlays, and the content renderers for agent output: markdown with TeX math, terminal output, file reads, diffs, search results, web retrieval, and JSON inspection. The renderers are built for untrusted model output — raw HTML is dropped, links are neutralized or opened safely, and ANSI escape sequences are parsed rather than passed through. User-facing copy is supplied through label props; the feature plugin that composes an atom owns localization. +`dsh-client-ui-primitives` is the web client's shared React component library: every feature plugin composes its UI from these atoms, and nothing here depends on Cordis or the slot system. It provides the control set (buttons, pills, inputs, menus, modals, toast banners, disclosure rows, hover cards, connection indicators), the icon glyphs and brand marks, positioning hooks for anchored overlays, and the content renderers for agent output: markdown with TeX math, terminal output, file reads, diffs, search results, web retrieval, and JSON inspection. The renderers are built for untrusted model output — raw HTML is dropped, links are neutralized or opened safely, and ANSI escape sequences are parsed rather than passed through. User-facing copy is supplied through label props; the feature plugin that composes an atom owns localization. ## Table of Contents @@ -29,7 +29,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionBanner`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and the `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. ### Rendering agent output @@ -37,7 +37,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Localizing copy -The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionBanner`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. +The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. ----- diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 0af43e4831..9f3c06cace 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-ui-primitives` 是 Web 客户端共享的 React 组件库:每个功能插件都用这些原子组件拼装自己的 UI,而这里没有任何内容依赖 Cordis 或 slot 系统。它提供控件集(按钮、胶囊、输入框、菜单、模态框、Toast 横幅、折叠行、悬浮卡片、连接横幅)、图标字形与品牌标记、锚定浮层用的定位钩子,以及 agent 输出的内容渲染器:带 TeX 公式的 markdown、终端输出、文件读取、差异、搜索结果、网页检索与 JSON 检查。这些渲染器为不受信任的模型输出而设计——原始 HTML 会被丢弃、链接会被失效或安全打开、ANSI 转义序列会被解析而非透传。面向用户的文案通过 label prop 提供;拼装某个原子组件的功能插件负责本地化。 +`dsh-client-ui-primitives` 是 Web 客户端共享的 React 组件库:每个功能插件都用这些原子组件拼装自己的 UI,而这里没有任何内容依赖 Cordis 或 slot 系统。它提供控件集(按钮、胶囊、输入框、菜单、模态框、Toast 横幅、折叠行、悬浮卡片、连接指示器)、图标字形与品牌标记、锚定浮层用的定位钩子,以及 agent 输出的内容渲染器:带 TeX 公式的 markdown、终端输出、文件读取、差异、搜索结果、网页检索与 JSON 检查。这些渲染器为不受信任的模型输出而设计——原始 HTML 会被丢弃、链接会被失效或安全打开、ANSI 转义序列会被解析而非透传。面向用户的文案通过 label prop 提供;拼装某个原子组件的功能插件负责本地化。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionBanner`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 ### 渲染 agent 输出 @@ -37,7 +37,7 @@ kind: "package-library" ### 本地化文案 -这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionBanner`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 +这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 ----- diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 1f148c26a5..0c53c53964 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, ConnectionIndicator, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) @@ -419,11 +419,37 @@ describe('Modal', () => { }) }) -describe('ConnectionBanner', () => { - it('renders only while reconnecting', () => { - const { container, rerender } = render() +describe('ConnectionIndicator', () => { + it('renders outage, attempt progress, and recovered states without a native tooltip', () => { + const reconnect = vi.fn() + const labels = { + disconnectedLabel: 'Connection issue', + reconnectLabel: 'Reconnect', + connectingLabel: 'Connecting', + recoveredLabel: 'Connected', + reconnectActionLabel: 'Connection issue, reconnect now', + restartActionLabel: 'Connecting, restart now', + onReconnect: reconnect, + } + const { container, rerender } = render( + , + ) expect(container.firstChild).toBeNull() - rerender() - expect(container.textContent).toContain('Reconnecting') + rerender() + const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' }) + expect(indicator.textContent).toContain('Connection issue') + expect(indicator.textContent).toContain('Reconnect') + expect(indicator.hasAttribute('title')).toBe(false) + expect(indicator.querySelector('svg')).toBeTruthy() + fireEvent.click(indicator) + expect(reconnect).toHaveBeenCalledOnce() + + rerender() + expect(screen.getByRole('button', { name: 'Connecting, restart now' }).textContent) + .toContain('Connecting...') + + rerender() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() }) }) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 93ac13b82e..f827b507f4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-general/README.md -README.md: f0c4ab7c50798919d42ade0eac6a9a93c8a4df1c -README.zh.md: 912ea0fb16c0d1d512ff846c8eda1e724664f1ea +README.md: acae84a4d4a5dddabb9e11135487911c84d47c96 +README.zh.md: db96d7d867a6d73aba75115c38d47937a1259ad4 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index f0c4ab7c50..acae84a4d4 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-settings-general` is the settings shell of the dsh web client: the Settings panel opens from the sidebar's bottom control with the trigger chrome and modal shell, the navigation is built from the sections features contribute, and first-run users are walked through one onboarding step at a time. It also registers everything on the Settings pages that belongs to no single feature: the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages; the shell itself ships no onboarding copy of its own. +`dsh-client-ui-settings-general` is the settings shell of the dsh web client: the Settings panel opens from the sidebar's bottom control, a connection-failure indicator beside that control offers immediate recovery, the navigation is built from the sections features contribute, and first-run users are walked through one onboarding step at a time. It also registers everything on the Settings pages that belongs to no single feature: the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages; the shell itself ships no onboarding copy of its own. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. After a Host connection failure, a pale-yellow **Connection issue** action appears to the right of Settings. Automatic recovery shows **Connecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. ### The General section @@ -53,6 +53,10 @@ The shell owns the chrome and the projections; every piece of content and copy b The navigation is a projection of the `settings.section` ledger; nav labels may be locale-following thunks, resolved through `resolveSlotLabel` and re-rendered on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order; the active registrant receives its id, `complete()`, and an `openSection(id)` callback, and completing or skipping transfers ownership to the next entry. +### Connection recovery + +The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer. + ### Document availability On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 912ea0fb16..db96d7d867 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-general` 是 dsh Web 客户端的设置外壳:Settings 面板从侧边栏底部的控件打开,带触发控件与模态外壳;导航由各功能贡献的分区构建;首次运行的用户一次只走一个引导步骤。它还注册设置页面上所有不属于单一功能的内容:触发器、标题栏与关闭控件界面框架、「本地配置文件」操作、「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)与条件式首次使用引导步骤仍由各自的功能包提供;外壳本身不自带任何引导文案。 +`dsh-client-ui-settings-general` 是 dsh Web 客户端的设置外壳:Settings 面板从侧边栏底部的控件打开,该控件旁的连接故障指示器提供即时恢复操作;导航由各功能贡献的分区构建;首次运行的用户一次只走一个引导步骤。它还注册设置页面上所有不属于单一功能的内容:触发器、标题栏与关闭控件界面框架、「本地配置文件」操作、「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)与条件式首次使用引导步骤仍由各自的功能包提供;外壳本身不自带任何引导文案。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Host 连接失败后,浅黄色的**连接异常**操作会出现在 Settings 右侧;自动恢复期间显示**连接中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。图标、文字起点、高度和宽度在所有可见状态中保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 ### 「通用」分区 @@ -53,6 +53,10 @@ kind: "package-reference" 导航是 `settings.section` 账本的投影;导航 label 可以是跟随语言的 thunk,经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。引导账本按升序投影;当前注册方会收到该条目的 id、`complete()` 与 `openSection(id)` 回调,完成或跳过当前步骤后,所有权转交给下一项。 +### 连接恢复 + +外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有。 + ### 文档可用性 在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染配置文件操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index a557855323..b7168e8f6c 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -8,6 +8,12 @@ export const zh = { 'openDocument': '打开配置文件', 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', + 'connection.error': '连接异常', + 'connection.retry': '立即重连', + 'connection.connecting': '连接中', + 'connection.connected': '连接成功', + 'connection.reconnect': '连接异常,点击立即重连', + 'connection.restart': '连接中,点击立即重连', } satisfies Record /** The settings namespace key union. */ @@ -21,4 +27,10 @@ export const en = { 'openDocument': 'Open configuration file', 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', + 'connection.error': 'Connection issue', + 'connection.retry': 'Reconnect now', + 'connection.connecting': 'Connecting', + 'connection.connected': 'Connected', + 'connection.reconnect': 'Connection issue, reconnect now', + 'connection.restart': 'Connecting, restart now', } satisfies Record 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 5b9c4d04ed..128332e258 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -47,6 +47,10 @@ async function bench(isLoopback = true) { }) // The fixed Host facts the shell reads its loopback-only action from. remote.$host = { home: undefined, isLoopback } + ctx.provide('connection', { + state: { getSnapshot: () => 'connected', subscribe: () => () => {} }, + reconnect: () => {}, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -75,7 +79,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.settings', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { @@ -123,8 +127,12 @@ describe('ui-settings-general apply', () => { const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(b.locale.bind('settings')('title')).toBe('设置') + expect(b.locale.bind('settings')('connection.error')).toBe('连接异常') + expect(b.locale.bind('settings')('connection.connecting')).toBe('连接中') + expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功') b.locale.setLocale('en') expect(b.locale.bind('settings')('close')).toBe('Close') + expect(b.locale.bind('settings')('connection.reconnect')).toBe('Connection issue, reconnect now') b.locale.setLocale('zh') await fiber.dispose() // The (ns, locale) seats are free again — the dictionary disposer ran. 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 7d61bbc78c..cce7ed163c 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 @@ -2,10 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' +import { en } from '../src/client/locales.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) type Row = { id: string; order: number; label: string } type Step = { id: string; order: number } @@ -19,11 +24,13 @@ const SEAT_CONTENT: Record = { } type AttentionSnapshot = Parameters[0]>[0] +type ConnectionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) function mount({ wide = true, + connectionState = 'connected', onboardingActive = true, rows = [ { id: 'general', order: 0, label: 'General' }, @@ -34,11 +41,20 @@ function mount({ { id: 'welcome', order: -100 }, { id: 'credential', order: 0 }, ], -}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) { +}: { + wide?: boolean + connectionState?: ConnectionSnapshot + onboardingActive?: boolean + rows?: Row[] + steps?: Step[] +} = {}) { // Mutable row source standing in for the bound useSections hook; bump() // plays a ledger change through the same observable contract. let current = rows + let currentConnectionState = connectionState const listeners = new Set<() => void>() + const connectionListeners = new Set<() => void>() + const reconnect = vi.fn() const renderSlot = vi.fn( ((key: string, _owner: unknown, opts?: { only?: string }) => { if (key === 'settings.section') return
@@ -58,6 +74,17 @@ function mount({ useSessionPendingInteraction, useWorkspaces: unusedHook, wide, + reconnect, + t: makeTranslate(en), + useConnectionState: (select) => { + const [, force] = useState(0) + useEffect(() => { + const listener = () => { force(n => n + 1) } + connectionListeners.add(listener) + return () => { connectionListeners.delete(listener) } + }, []) + return select(currentConnectionState) + }, useOnboardingSteps: select => select(steps), useSections: (select) => { const [, force] = useState(0) @@ -77,7 +104,13 @@ function mount({ for (const fn of [...listeners]) fn() }) } - return { view, renderSlot, bump, listeners } + const setConnectionState = (next: typeof currentConnectionState) => { + act(() => { + currentConnectionState = next + for (const fn of [...connectionListeners]) fn() + }) + } + return { view, renderSlot, bump, listeners, reconnect, setConnectionState } } function openPanel() { @@ -103,6 +136,36 @@ describe('SettingsRoot trigger', () => { const { renderSlot } = mount({ wide: false }) expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false }) }) + + it('shows outage, retry progress, and a two-second recovery confirmation', () => { + vi.useFakeTimers() + const mounted = mount() + expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull() + + mounted.setConnectionState('disconnected') + const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' }) + expect(indicator.textContent).toContain('Connection issue') + expect(indicator.hasAttribute('title')).toBe(false) + expect(indicator.querySelector('svg')).toBeTruthy() + fireEvent.click(indicator) + expect(mounted.reconnect).toHaveBeenCalledOnce() + + mounted.setConnectionState('connecting') + expect(screen.getByRole('button', { name: 'Connecting, restart now' }).textContent) + .toContain('Connecting...') + + mounted.setConnectionState('connected') + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(1_999) }) + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('keeps the reconnect indicator out of the collapsed rail', () => { + mount({ wide: false, connectionState: 'disconnected' }) + expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull() + }) }) describe('SettingsPanel chrome seats', () => { 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 9dbb7ebafe..8e33a75a10 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -24,6 +24,12 @@ async function bench() { const settings = { describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), } + const reconnect = vi.fn() + const connectionState = { + getSnapshot: () => 'connected' as const, + subscribe: () => () => {}, + } + ctx.provide('connection', { state: connectionState, reconnect } as never) ctx.provide('remote', { $on: () => () => {}, $host: { home: undefined, isLoopback: false }, @@ -31,7 +37,7 @@ async function bench() { } as never) ctx.provide('remote.settings', settings as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry } + return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect } } function declare(slots: SlotRegistry): () => void { @@ -59,7 +65,7 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', ]) }) @@ -111,6 +117,16 @@ describe('ui-settings apply', () => { off() }) + it('projects the Gateway connection control without copying its state', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + expect(injected.hooks.connectionState).toBe(b.connectionState) + injected.reconnect() + expect(b.reconnect).toHaveBeenCalledOnce() + }) + it('projects onboarding entries into stable coordinator order', async () => { const b = await bench() declare(b.slots) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 47713be8ec..bef6356509 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -499,12 +499,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', declaration: 'export interface ConnectionHostInfo {\n readonly home: string;\n}', }, + { + name: 'ConnectionLoop', + declaration: 'export interface ConnectionLoop {\n stop(): void;\n}', + }, { name: 'ConnectionRpcFailure', declaration: 'export interface ConnectionRpcFailure {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n}', @@ -515,11 +519,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionSinks', - declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', + declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n onReconnectRequested?: () => void;\n}', }, { name: 'ConnectionState', - declaration: 'export type ConnectionState = \'connected\' | \'reconnecting\';', + declaration: 'export type ConnectionState = \'connected\' | \'disconnected\' | \'connecting\';', + }, + { + name: 'ConnectionStateSource', + declaration: 'export interface ConnectionStateSource {\n getSnapshot(): ConnectionState | undefined;\n subscribe(listener: () => void): () => void;\n}', }, { name: 'EntryKeyOf', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b37eee6d1..f790a3c1a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3106,6 +3106,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/snapshots/web/lifecycle-chrome/connection-error.expected.md b/snapshots/web/lifecycle-chrome/connection-error.expected.md new file mode 100644 index 0000000000..ded710efed --- /dev/null +++ b/snapshots/web/lifecycle-chrome/connection-error.expected.md @@ -0,0 +1,4 @@ +- button "设置": + - img + - text: 设置 +- button "连接异常,点击立即重连": 连接异常 From 84c7ae3398baf42e9fec1b181f73bb4032cd7557 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:17:55 +0800 Subject: [PATCH 067/429] fix(web): align connection indicator labels --- ...-web-connection-recovery-control.i18n.yaml | 4 +-- ...6-08-28-web-connection-recovery-control.md | 2 +- ...8-28-web-connection-recovery-control.zh.md | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 34 ++++++++++++------- .../src/ConnectionIndicator.module.css | 1 + .../ui-primitives/tests/atoms.client.spec.tsx | 8 ++--- .../ui-settings-general/README.i18n.yaml | 4 +-- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../ui-settings-general/src/client/locales.ts | 4 +-- .../tests/apply.client.spec.ts | 2 +- .../tests/settings-root.client.spec.tsx | 8 ++--- .../connection-error.expected.md | 6 ++-- 13 files changed, 45 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml index 81a25f4ac3..fe9cc90b7a 100644 --- a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.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-28-web-connection-recovery-control.md -2026-08-28-web-connection-recovery-control.md: 7adf372498105bad643606cf7cd99c71406b634b -2026-08-28-web-connection-recovery-control.zh.md: c1172c530035590ebcd636e8735bc378c15bc69d +2026-08-28-web-connection-recovery-control.md: 6fec265c8e166836a5ab9413f1612cdaa6461a1d +2026-08-28-web-connection-recovery-control.zh.md: e45119b272c297a783a650b71743e4f81c1a1565 diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md index 7adf372498..6fec265c8e 100644 --- a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md @@ -16,7 +16,7 @@ The Client Connection service exposes the identity-stable `ctx.connection.state` The [Web Client architecture](../architecture/2026-07-19-gui-web-client-architecture.md), [Remote event delivery](../architecture/2026-08-10-remote-event-delivery.md), and [Session event transport](../architecture/2026-08-18-session-history-and-event-transport.md) retain their broader ownership decisions; this note supersedes only their former retry timing. -The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Connection issue** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing. +The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Disconnected** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and left-aligned text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md index c1172c5300..e45119b272 100644 --- a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md @@ -16,7 +16,7 @@ Client Connection 服务暴露 identity 稳定的 `ctx.connection.state` observa [Web Client 架构](../architecture/2026-07-19-gui-web-client-architecture.zh.md)、[Remote 事件投递](../architecture/2026-08-10-remote-event-delivery.zh.md)和[会话事件传输](../architecture/2026-08-18-session-history-and-event-transport.zh.md)继续持有各自更宽的所有权决策;本笔记只取代其中原有的重试时序。 -Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。 +Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和左对齐文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。 ## Alternatives considered diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 625ddd73b2..428e3c195a 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -23,7 +23,7 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) @@ -286,10 +286,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () }, 60_000) it.skipIf(MODE === 'record')('shows automatic and user-requested connection recovery beside Settings', async () => { - const recoveryPage = await browser.newPage({ - viewport: { width: 1680, height: 1000 }, - locale: ZH_BROWSER_LOCALE, - }) + const recoveryPage = await newEnglishPage(browser) const recoveryTripwire = watchConsole(recoveryPage) const sockets: WebSocketRoute[] = [] let rejectConnections = false @@ -310,7 +307,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await recoveryPage.context().setOffline(true) await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(false) const offline = recoveryPage.getByRole('button', { - name: '连接异常,点击立即重连', exact: true, + name: 'Disconnected, reconnect now', exact: true, }) await offline.waitFor({ timeout: 2_000 }) await recoveryPage.waitForTimeout(750) @@ -319,22 +316,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await recoveryPage.context().setOffline(false) await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true) const connecting = recoveryPage.getByRole('button', { - name: '连接中,点击立即重连', exact: true, + name: 'Connecting, restart now', exact: true, }) await connecting.waitFor({ timeout: 10_000 }) - expect(await connecting.innerText()).toMatch(/^连接中\.{1,3}$/) + expect(await connecting.innerText()).toMatch(/^Connecting\.{1,3}$/) const connectingGeometry = await connectionIndicatorGeometry(connecting) + expect(await connectionIndicatorTextAlignment(connecting)).toBe('left') await connecting.hover() - expect(await connecting.innerText()).toBe('立即重连') + expect(await connecting.innerText()).toBe('Reconnect now') expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) await recoveryPage.mouse.move(0, 0) await expect.poll(() => sockets.length, { timeout: 40_000 }).toBe(7) const indicator = recoveryPage.getByRole('button', { - name: '连接异常,点击立即重连', exact: true, + name: 'Disconnected, reconnect now', exact: true, }) await indicator.waitFor({ timeout: 10_000 }) expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry) + expect(await connectionIndicatorTextAlignment(indicator)).toBe('left') const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE) const style = await indicator.evaluate((element) => { @@ -359,7 +358,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(await indicator.getAttribute('title')).toBeNull() const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) await indicator.hover() - expect(await indicator.innerText()).toBe('立即重连') + expect(await indicator.innerText()).toBe('Reconnect now') const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) expect(hoverBackground).toBe(idleBackground) await recoveryPage.mouse.down() @@ -371,8 +370,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => sockets.length).toBe(8) const recovered = recoveryPage.getByRole('status') await recovered.waitFor({ timeout: 10_000 }) - expect(await recovered.innerText()).toBe('连接成功') + expect(await recovered.innerText()).toBe('Connected') expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry) + expect(await connectionIndicatorTextAlignment(recovered)).toBe('left') await recovered.waitFor({ state: 'detached', timeout: 5_000 }) expect(recoveryTripwire.pageErrors).toEqual([]) expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #[1-6]/i.test(warning))) @@ -410,3 +410,13 @@ async function connectionIndicatorGeometry(locator: ReturnType, +): Promise { + return await locator.evaluate((element) => { + const label = element.children.item(1) + if (label === null) throw new Error('connection indicator label missing') + return getComputedStyle(label).textAlign + }) +} diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index 9c46f7a6e4..9fe904c712 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -50,6 +50,7 @@ .label { display: grid; + text-align: left; } .stateLabel, diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 0c53c53964..93df7e4941 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -423,11 +423,11 @@ describe('ConnectionIndicator', () => { it('renders outage, attempt progress, and recovered states without a native tooltip', () => { const reconnect = vi.fn() const labels = { - disconnectedLabel: 'Connection issue', + disconnectedLabel: 'Disconnected', reconnectLabel: 'Reconnect', connectingLabel: 'Connecting', recoveredLabel: 'Connected', - reconnectActionLabel: 'Connection issue, reconnect now', + reconnectActionLabel: 'Disconnected, reconnect now', restartActionLabel: 'Connecting, restart now', onReconnect: reconnect, } @@ -436,8 +436,8 @@ describe('ConnectionIndicator', () => { ) expect(container.firstChild).toBeNull() rerender() - const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' }) - expect(indicator.textContent).toContain('Connection issue') + const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' }) + expect(indicator.textContent).toContain('Disconnected') expect(indicator.textContent).toContain('Reconnect') expect(indicator.hasAttribute('title')).toBe(false) expect(indicator.querySelector('svg')).toBeTruthy() diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index f827b507f4..d6ddb4c02f 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-general/README.md -README.md: acae84a4d4a5dddabb9e11135487911c84d47c96 -README.zh.md: db96d7d867a6d73aba75115c38d47937a1259ad4 +README.md: 1d231c2c1de2d8870c6f16e0d993b2387c876e4d +README.zh.md: 13fe7b9f4a58f0f8836437485333127e76e2410e diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index acae84a4d4..1d231c2c1d 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. After a Host connection failure, a pale-yellow **Connection issue** action appears to the right of Settings. Automatic recovery shows **Connecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. After a Host connection failure, a pale-yellow **Disconnected** action appears to the right of Settings. Automatic recovery shows **Connecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. ### The General section diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index db96d7d867..13fe7b9f4a 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Host 连接失败后,浅黄色的**连接异常**操作会出现在 Settings 右侧;自动恢复期间显示**连接中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。图标、文字起点、高度和宽度在所有可见状态中保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Host 连接失败后,浅黄色的**连接异常**操作会出现在 Settings 右侧;自动恢复期间显示**连接中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 ### 「通用」分区 diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index b7168e8f6c..c40d7f78af 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -27,10 +27,10 @@ export const en = { 'openDocument': 'Open configuration file', 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', - 'connection.error': 'Connection issue', + 'connection.error': 'Disconnected', 'connection.retry': 'Reconnect now', 'connection.connecting': 'Connecting', 'connection.connected': 'Connected', - 'connection.reconnect': 'Connection issue, reconnect now', + 'connection.reconnect': 'Disconnected, reconnect now', 'connection.restart': 'Connecting, restart now', } satisfies Record 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 128332e258..8a0c742e43 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -132,7 +132,7 @@ describe('ui-settings-general apply', () => { expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功') b.locale.setLocale('en') expect(b.locale.bind('settings')('close')).toBe('Close') - expect(b.locale.bind('settings')('connection.reconnect')).toBe('Connection issue, reconnect now') + expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now') b.locale.setLocale('zh') await fiber.dispose() // The (ns, locale) seats are free again — the dictionary disposer ran. 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 cce7ed163c..556c095f3d 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 @@ -140,11 +140,11 @@ describe('SettingsRoot trigger', () => { it('shows outage, retry progress, and a two-second recovery confirmation', () => { vi.useFakeTimers() const mounted = mount() - expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Disconnected, reconnect now' })).toBeNull() mounted.setConnectionState('disconnected') - const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' }) - expect(indicator.textContent).toContain('Connection issue') + const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' }) + expect(indicator.textContent).toContain('Disconnected') expect(indicator.hasAttribute('title')).toBe(false) expect(indicator.querySelector('svg')).toBeTruthy() fireEvent.click(indicator) @@ -164,7 +164,7 @@ describe('SettingsRoot trigger', () => { it('keeps the reconnect indicator out of the collapsed rail', () => { mount({ wide: false, connectionState: 'disconnected' }) - expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Disconnected, reconnect now' })).toBeNull() }) }) diff --git a/snapshots/web/lifecycle-chrome/connection-error.expected.md b/snapshots/web/lifecycle-chrome/connection-error.expected.md index ded710efed..92c807a650 100644 --- a/snapshots/web/lifecycle-chrome/connection-error.expected.md +++ b/snapshots/web/lifecycle-chrome/connection-error.expected.md @@ -1,4 +1,4 @@ -- button "设置": +- button "Settings": - img - - text: 设置 -- button "连接异常,点击立即重连": 连接异常 + - text: Settings +- button "Disconnected, reconnect now": Disconnected From 1f8d7b73af84bee0c88b43812648c32368135141 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 11:29:46 +0800 Subject: [PATCH 068/429] fix(snapshot): own fixture listener lifecycle --- scripts/snapshot-http-fixtures.spec.ts | 193 ++++++++++++++++++ snapshots/session/headless.snapshot.ts | 74 ++++++- .../web-fetch/web-fetch-fixture-server.mjs | 100 ++++++--- .../web-search-error-fixture.mjs | 133 +++++++++--- 4 files changed, 430 insertions(+), 70 deletions(-) create mode 100644 scripts/snapshot-http-fixtures.spec.ts diff --git a/scripts/snapshot-http-fixtures.spec.ts b/scripts/snapshot-http-fixtures.spec.ts new file mode 100644 index 0000000000..d54a4a3aa4 --- /dev/null +++ b/scripts/snapshot-http-fixtures.spec.ts @@ -0,0 +1,193 @@ +import { EventEmitter } from 'node:events' +import { Context } from '@deepseek-ai/cordis' +import { WebRuntime } from '@deepseek-ai/dsh-web' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const httpMock = vi.hoisted(() => ({ createServer: vi.fn() })) + +vi.mock('node:http', () => ({ createServer: httpMock.createServer })) + +// Snapshot plugins are plain runtime JavaScript loaded by cordis.yml. +// @ts-expect-error The fixture intentionally has no declaration artifact. +import * as searchFixtureModule from '../snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs' +// @ts-expect-error The fixture intentionally has no declaration artifact. +import * as webFetchFixtureModule from '../snapshots/session/web-fetch/web-fetch-fixture-server.mjs' + +const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' +const RECORDED_URL = 'http://public.test:43117/menu.html' + +interface FixturePlugin { + readonly name: string + readonly inject?: readonly string[] + apply(ctx: Context): Promise +} + +const searchFixture = searchFixtureModule as unknown as FixturePlugin +const webFetchFixture = webFetchFixtureModule as unknown as FixturePlugin +const nativeFetch = globalThis.fetch + +class FixtureServer extends EventEmitter { + readonly started = Promise.withResolvers() + listening = false + closed = false + connectionsClosed = false + unreferenced = false + private listenCallback: (() => void) | undefined + private port = 0 + + listen(_port: number, _host: string, callback: () => void): this { + this.listenCallback = callback + this.started.resolve(undefined) + return this + } + + finishListening(port = 54321): void { + this.port = port + this.listening = true + this.listenCallback?.() + } + + address(): { address: string; family: string; port: number } | null { + return this.listening ? { address: '127.0.0.1', family: 'IPv4', port: this.port } : null + } + + unref(): this { + this.unreferenced = true + return this + } + + close(callback: (error?: Error) => void): this { + this.listening = false + this.closed = true + callback() + return this + } + + closeAllConnections(): void { + this.connectionsClosed = true + } +} + +function nextServer(): FixtureServer { + const server = new FixtureServer() + httpMock.createServer.mockReturnValueOnce(server) + return server +} + +function captureErrors(ctx: Context): unknown[] { + const errors: unknown[] = [] + ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error + return errors +} + +afterEach(() => { + globalThis.fetch = nativeFetch + httpMock.createServer.mockReset() +}) + +describe('snapshot HTTP fixture lifecycle', () => { + it('joins search listener setup and cleanup when disposal wins the startup race', async () => { + const server = nextServer() + const ctx = new Context() + const errors = captureErrors(ctx) + const fiber = ctx.plugin(searchFixture) + await server.started.promise + + const disposal = fiber.dispose() + const settled = vi.fn() + void disposal.then(settled) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + server.finishListening() + await disposal + + expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true }) + expect(globalThis.fetch).toBe(nativeFetch) + expect(errors).toEqual([]) + }) + + it('removes the fetch provider and closes its listener when disposal wins the startup race', async () => { + const server = nextServer() + const ctx = new Context() + await ctx.plugin(WebRuntime) + const errors = captureErrors(ctx) + const fiber = ctx.plugin(webFetchFixture) + await server.started.promise + + const disposal = fiber.dispose() + const settled = vi.fn() + void disposal.then(settled) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + server.finishListening() + await disposal + + expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true }) + await expect(ctx.web.fetch({ url: RECORDED_URL })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + expect(errors).toEqual([]) + }) + + it('maps every fetch input form and rejects another path on the recorded authority', async () => { + const server = nextServer() + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}')) + globalThis.fetch = fetchMock + const ctx = new Context() + const fiber = ctx.plugin(searchFixture) + await server.started.promise + server.finishListening(54322) + await fiber + + try { + await globalThis.fetch(RECORDED_ENDPOINT) + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages') + + await globalThis.fetch(new URL(RECORDED_ENDPOINT)) + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages') + + const request = new Request(RECORDED_ENDPOINT, { method: 'POST', headers: { 'x-fixture': 'request' } }) + await globalThis.fetch(request) + const mappedRequest = fetchMock.mock.calls.at(-1)?.[0] + expect(mappedRequest).toBeInstanceOf(Request) + if (!(mappedRequest instanceof Request)) throw new TypeError('mapped fetch input must be a Request') + expect(mappedRequest.url).toBe('http://127.0.0.1:54322/anthropic/v1/messages') + expect(mappedRequest.method).toBe('POST') + expect(mappedRequest.headers.get('x-fixture')).toBe('request') + + const unrelated = new URL('https://example.test/') + await globalThis.fetch(unrelated) + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe(unrelated) + + await expect(globalThis.fetch('http://127.0.0.1:43118/unexpected')) + .rejects.toThrow('web-search-error-fixture: unexpected URL for recorded authority') + } finally { + await fiber.dispose() + } + + expect(globalThis.fetch).toBe(fetchMock) + expect(server.closed).toBe(true) + }) + + it('preserves a later fetch wrapper while still closing the listener and reporting the ownership error', async () => { + const server = nextServer() + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}')) + globalThis.fetch = fetchMock + const ctx = new Context() + const errors = captureErrors(ctx) + const fiber = ctx.plugin(searchFixture) + await server.started.promise + server.finishListening() + await fiber + + const fixtureFetch = globalThis.fetch + const laterFetch = vi.fn((input: string | URL | Request, init?: RequestInit) => fixtureFetch(input, init)) + globalThis.fetch = laterFetch + await fiber.dispose() + + expect(globalThis.fetch).toBe(laterFetch) + expect(server).toMatchObject({ closed: true, connectionsClosed: true }) + expect(errors.map(String).join('\n')).toContain('web-search-error-fixture: global fetch owner changed before cleanup') + }) +}) diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 1579c5d04e..5da4cb02e9 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -7,6 +7,7 @@ import { homedir } from 'node:os' import { basename, delimiter, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import ts from 'typescript' import { captureExpectedWorkspaceSnapshot, captureWorkspaceSnapshot, @@ -84,6 +85,49 @@ interface SessionLog { readonly header: JsonObject } +function propertyName(node: ts.PropertyName): string | undefined { + if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text + return undefined +} + +function bindsOsAssignedPort(argument: ts.Expression | undefined): boolean { + if (argument === undefined) return false + if (ts.isNumericLiteral(argument)) return Number(argument.text) === 0 + if (!ts.isObjectLiteralExpression(argument)) return false + let portIsZero: boolean | undefined + for (const property of argument.properties) { + if (ts.isSpreadAssignment(property)) { + portIsZero = undefined + continue + } + if (propertyName(property.name) !== 'port') continue + portIsZero = ts.isPropertyAssignment(property) + && ts.isNumericLiteral(property.initializer) + && Number(property.initializer.text) === 0 + } + return portIsZero === true +} + +function listenerPortViolations(path: string, sourceText: string): string[] { + const source = ts.createSourceFile(path, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + const violations: string[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === 'listen' + && !bindsOsAssignedPort(node.arguments[0])) { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + const received = node.arguments[0]?.getText(source) ?? '' + violations.push( + `${path}:${line}: listener port ${received} must use listen(0, ...) or listen({ port: 0, ... })`, + ) + } + ts.forEachChild(node, visit) + } + visit(source) + return violations +} + function harvested(log: SessionLog): HarvestedLog { return { id: String(log.header.id), @@ -507,17 +551,27 @@ describe('headless recorded-session snapshots', () => { } }) + it('recognizes the supported OS-assigned listener forms', () => { + expect(listenerPortViolations('accepted.mjs', [ + "server.listen(0, '127.0.0.1')", + "server.listen({ port: 0, host: '127.0.0.1' })", + 'server.listen({ ...options, port: 0 })', + ].join('\n'))).toEqual([]) + expect(listenerPortViolations('fixed.mjs', 'server.listen(43118)')).toEqual([ + 'fixed.mjs:1: listener port 43118 must use listen(0, ...) or listen({ port: 0, ... })', + ]) + expect(listenerPortViolations('dynamic.mjs', 'server.listen({ port, ...options })')).toEqual([ + 'dynamic.mjs:1: listener port { port, ...options } must use listen(0, ...) or listen({ port: 0, ... })', + ]) + }) + it('binds scenario HTTP fixtures only to OS-assigned ports', async () => { - for (const scenario of scenarios) { - const fixtureNames = (await readdir(scenario.dir)).filter(name => name.endsWith('.mjs')) - for (const fixtureName of fixtureNames) { - const source = await readFile(join(scenario.dir, fixtureName), 'utf8') - const boundPorts = [...source.matchAll(/\.listen\(\s*([^,\s)]+)/gu)].map(match => match[1]) - for (const port of boundPorts) { - expect(port, `${scenario.name}/${fixtureName}: listener port`).toBe('0') - } - } - } + const fixtureNames = (await readdir(snapshotsRoot, { recursive: true })).filter(name => name.endsWith('.mjs')) + const violations = (await Promise.all(fixtureNames.map(async (fixtureName) => listenerPortViolations( + fixtureName, + await readFile(join(snapshotsRoot, fixtureName), 'utf8'), + )))).flat() + expect(violations).toEqual([]) }) it('stores session-owned inputs with typed redaction and no ACP transcript', async () => { diff --git a/snapshots/session/web-fetch/web-fetch-fixture-server.mjs b/snapshots/session/web-fetch/web-fetch-fixture-server.mjs index f0b8db6ebb..2979917043 100644 --- a/snapshots/session/web-fetch/web-fetch-fixture-server.mjs +++ b/snapshots/session/web-fetch/web-fetch-fixture-server.mjs @@ -36,54 +36,94 @@ const LIMITS = { userAgent: 'deepseek-harness-snapshot/1.0', } +function listen(server) { + return new Promise((resolve, reject) => { + const onError = (error) => { + server.off('error', onError) + reject(error) + } + server.once('error', onError) + try { + server.listen(0, '127.0.0.1', () => { + server.off('error', onError) + resolve(undefined) + }) + } catch (error) { + server.off('error', onError) + reject(error) + } + }) +} + +async function close(server) { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve(undefined)) + server.closeAllConnections() + }) +} + /** * Register the deterministic provider and start its loopback server. * @param ctx - Cordis context; the effect disposes the server with the fiber. */ export async function apply(ctx) { - const server = createServer((req, res) => { - if (req.url === '/menu.html') { - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) - res.end(PAGE) - return - } - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) - res.end('not found') - }) - const listening = new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => resolve(undefined)) - }) - await listening - const address = server.address() - if (address === null || typeof address === 'string') { - throw new Error('web-fetch-fixture-server: loopback listener has no TCP address') - } - const transportUrl = new URL(RECORDED_URL) - transportUrl.port = String(address.port) - // The fixture must never hold the process open past protocol shutdown. - server.unref() + const readiness = Promise.withResolvers() + let transportUrl + let startupError const resolveAddresses = async (hostname) => { if (hostname !== 'public.test') throw new Error(`unexpected snapshot hostname: ${hostname}`) return [{ address: '127.0.0.1', family: 4 }] } - ctx.effect(() => async () => { - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve(undefined)) - // Stop accepting first so a connection cannot arrive after the forced close. - server.closeAllConnections() - }) - }, 'web-fetch-fixture-server') const provider = new HttpFetchProvider(LIMITS, resolveAddresses) - ctx.web.registerFetchProvider({ + const unregister = ctx.web.registerFetchProvider({ id: provider.id, available: () => provider.available(), fetch: async (request, signal) => { if (request.url !== RECORDED_URL) throw new Error(`unexpected snapshot URL: ${request.url}`) + await readiness.promise + if (startupError !== undefined) throw startupError const result = await provider.fetch({ url: transportUrl.toString() }, signal) return { ...result, url: RECORDED_URL } }, }) + await ctx.effect(async () => { + const server = createServer((req, res) => { + if (req.url === '/menu.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(PAGE) + return + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('not found') + }) + try { + await listen(server) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('web-fetch-fixture-server: loopback listener has no TCP address') + } + transportUrl = new URL(RECORDED_URL) + transportUrl.port = String(address.port) + // The fixture must never hold the process open past protocol shutdown. + server.unref() + readiness.resolve(undefined) + return async () => { + unregister() + await close(server) + } + } catch (cause) { + startupError = cause + readiness.resolve(undefined) + unregister() + try { + await close(server) + } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], 'web-fetch-fixture-server: setup and cleanup failed') + } + throw cause + } + }, 'web-fetch-fixture-server') } diff --git a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs index 314f9b2dee..ce2087aff6 100644 --- a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs +++ b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs @@ -3,42 +3,115 @@ import { createServer } from 'node:http' /** Model-visible endpoint retained by the recorded session. */ const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' +const RECORDED_URL = new URL(RECORDED_ENDPOINT) /** Cordis plugin name. */ export const name = 'web-search-error-fixture' +function listen(server) { + return new Promise((resolve, reject) => { + const onError = (error) => { + server.off('error', onError) + reject(error) + } + server.once('error', onError) + try { + server.listen(0, '127.0.0.1', () => { + server.off('error', onError) + resolve(undefined) + }) + } catch (error) { + server.off('error', onError) + reject(error) + } + }) +} + +async function close(server) { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve(undefined)) + server.closeAllConnections() + }) +} + +function requestUrl(input) { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + if (input instanceof Request) return input.url + return undefined +} + +function transportInput(input, transportEndpoint) { + const url = requestUrl(input) + if (url === RECORDED_ENDPOINT) { + return input instanceof Request ? new Request(transportEndpoint, input) : transportEndpoint + } + if (url === undefined) return input + let parsed + try { + parsed = new URL(url) + } catch { + return input + } + if (parsed.host === RECORDED_URL.host) { + throw new Error(`web-search-error-fixture: unexpected URL for recorded authority: ${url}`) + } + return input +} + +async function cleanup(server, restoreFetch) { + const errors = [] + try { + restoreFetch() + } catch (error) { + errors.push(error) + } + try { + await close(server) + } catch (error) { + errors.push(error) + } + if (errors.length === 1) throw errors[0] + if (errors.length > 1) throw new AggregateError(errors, 'web-search-error-fixture: cleanup failed') +} + /** Start the local Messages endpoint and stop it with the plugin fiber. */ export async function apply(ctx) { - const server = createServer((request, response) => { - if (request.method === 'POST' && request.url === '/anthropic/v1/messages') { - response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } })) - return - } - response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) - response.end('not found') - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => resolve(undefined)) - }) - const address = server.address() - if (address === null || typeof address === 'string') { - throw new Error('web-search-error-fixture: loopback listener has no TCP address') - } - const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` - const originalFetch = globalThis.fetch - const fixtureFetch = (input, init) => originalFetch( - typeof input === 'string' && input === RECORDED_ENDPOINT ? transportEndpoint : input, - init, - ) - globalThis.fetch = fixtureFetch - server.unref() - ctx.effect(() => async () => { - globalThis.fetch = originalFetch - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve(undefined)) - server.closeAllConnections() + await ctx.effect(async () => { + const server = createServer((request, response) => { + if (request.method === 'POST' && request.url === '/anthropic/v1/messages') { + response.writeHead(401, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } })) + return + } + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('not found') }) + try { + await listen(server) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('web-search-error-fixture: loopback listener has no TCP address') + } + const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` + server.unref() + const originalFetch = globalThis.fetch + const fixtureFetch = async (input, init) => originalFetch(transportInput(input, transportEndpoint), init) + globalThis.fetch = fixtureFetch + return () => cleanup(server, () => { + if (globalThis.fetch !== fixtureFetch) { + throw new Error('web-search-error-fixture: global fetch owner changed before cleanup') + } + globalThis.fetch = originalFetch + }) + } catch (cause) { + try { + await close(server) + } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], 'web-search-error-fixture: setup and cleanup failed') + } + throw cause + } }, 'web-search-error-fixture') } From 7fbd33de00d0244dbca7906bc2cf4b3ae42a9154 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 11:50:13 +0800 Subject: [PATCH 069/429] fix(snapshot): keep fixture lifecycle tests source-clean --- scripts/snapshot-http-fixtures.spec.ts | 68 ++++++++------ snapshots/session/loopback-fixture-server.mjs | 75 ++++++++++++++++ .../web-fetch/web-fetch-fixture-server.mjs | 90 ++++++------------- .../web-search-error-fixture.mjs | 75 +++------------- 4 files changed, 151 insertions(+), 157 deletions(-) create mode 100644 snapshots/session/loopback-fixture-server.mjs diff --git a/scripts/snapshot-http-fixtures.spec.ts b/scripts/snapshot-http-fixtures.spec.ts index d54a4a3aa4..709b0638b4 100644 --- a/scripts/snapshot-http-fixtures.spec.ts +++ b/scripts/snapshot-http-fixtures.spec.ts @@ -1,6 +1,5 @@ import { EventEmitter } from 'node:events' import { Context } from '@deepseek-ai/cordis' -import { WebRuntime } from '@deepseek-ai/dsh-web' import { afterEach, describe, expect, it, vi } from 'vitest' const httpMock = vi.hoisted(() => ({ createServer: vi.fn() })) @@ -11,10 +10,9 @@ vi.mock('node:http', () => ({ createServer: httpMock.createServer })) // @ts-expect-error The fixture intentionally has no declaration artifact. import * as searchFixtureModule from '../snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs' // @ts-expect-error The fixture intentionally has no declaration artifact. -import * as webFetchFixtureModule from '../snapshots/session/web-fetch/web-fetch-fixture-server.mjs' +import * as loopbackFixtureModule from '../snapshots/session/loopback-fixture-server.mjs' const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' -const RECORDED_URL = 'http://public.test:43117/menu.html' interface FixturePlugin { readonly name: string @@ -22,8 +20,18 @@ interface FixturePlugin { apply(ctx: Context): Promise } +interface LoopbackFixtureOptions { + readonly label: string + readonly onCleanup: () => void + readonly onListening: (address: { port: number }) => void + readonly requestListener: () => void +} + const searchFixture = searchFixtureModule as unknown as FixturePlugin -const webFetchFixture = webFetchFixtureModule as unknown as FixturePlugin +const typedLoopbackFixtureModule = loopbackFixtureModule as unknown as { + readonly applyLoopbackServerEffect: (ctx: Context, options: LoopbackFixtureOptions) => Promise +} +const { applyLoopbackServerEffect } = typedLoopbackFixtureModule const nativeFetch = globalThis.fetch class FixtureServer extends EventEmitter { @@ -80,6 +88,17 @@ function captureErrors(ctx: Context): unknown[] { return errors } +async function disposeWhileStarting(fiber: { dispose(): Promise }, server: FixtureServer): Promise { + await server.started.promise + const disposal = fiber.dispose() + const settled = vi.fn() + void disposal.then(settled) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + server.finishListening() + await disposal +} + afterEach(() => { globalThis.fetch = nativeFetch httpMock.createServer.mockReset() @@ -91,42 +110,33 @@ describe('snapshot HTTP fixture lifecycle', () => { const ctx = new Context() const errors = captureErrors(ctx) const fiber = ctx.plugin(searchFixture) - await server.started.promise - - const disposal = fiber.dispose() - const settled = vi.fn() - void disposal.then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - - server.finishListening() - await disposal + await disposeWhileStarting(fiber, server) expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true }) expect(globalThis.fetch).toBe(nativeFetch) expect(errors).toEqual([]) }) - it('removes the fetch provider and closes its listener when disposal wins the startup race', async () => { + it('runs owner cleanup and closes the listener when disposal wins the startup race', async () => { const server = nextServer() const ctx = new Context() - await ctx.plugin(WebRuntime) const errors = captureErrors(ctx) - const fiber = ctx.plugin(webFetchFixture) - await server.started.promise - - const disposal = fiber.dispose() - const settled = vi.fn() - void disposal.then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - - server.finishListening() - await disposal + const onCleanup = vi.fn() + const onListening = vi.fn() + const fiber = ctx.plugin({ + name: 'loopback-fixture-lifecycle-test', + apply: testCtx => applyLoopbackServerEffect(testCtx, { + label: 'loopback-fixture-lifecycle-test', + onCleanup, + onListening, + requestListener: () => {}, + }), + }) + await disposeWhileStarting(fiber, server) expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true }) - await expect(ctx.web.fetch({ url: RECORDED_URL })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + expect(onListening).toHaveBeenCalledWith(expect.objectContaining({ port: 54321 })) + expect(onCleanup).toHaveBeenCalledOnce() expect(errors).toEqual([]) }) diff --git a/snapshots/session/loopback-fixture-server.mjs b/snapshots/session/loopback-fixture-server.mjs new file mode 100644 index 0000000000..c52d3653c3 --- /dev/null +++ b/snapshots/session/loopback-fixture-server.mjs @@ -0,0 +1,75 @@ +/** Shared lifecycle for snapshot HTTP fixtures that bind an ephemeral loopback port. */ +import { createServer } from 'node:http' + +function listen(server) { + return new Promise((resolve, reject) => { + const onError = (error) => { + server.off('error', onError) + reject(error) + } + server.once('error', onError) + try { + server.listen(0, '127.0.0.1', () => { + server.off('error', onError) + resolve(undefined) + }) + } catch (error) { + server.off('error', onError) + reject(error) + } + }) +} + +async function close(server) { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve(undefined)) + server.closeAllConnections() + }) +} + +async function cleanup(server, onCleanup, label) { + const errors = [] + try { + onCleanup() + } catch (error) { + errors.push(error) + } + try { + await close(server) + } catch (error) { + errors.push(error) + } + if (errors.length === 1) throw errors[0] + if (errors.length > 1) throw new AggregateError(errors, `${label}: cleanup failed`) +} + +/** + * Start a loopback server as a Cordis effect and join cleanup with its setup. + * @param ctx - Cordis context that owns the listener effect. + * @param options - Fixture callbacks and the effect label used in diagnostics. + */ +export async function applyLoopbackServerEffect(ctx, options) { + const { label, onCleanup, onListening, requestListener } = options + await ctx.effect(async () => { + const server = createServer(requestListener) + try { + await listen(server) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error(`${label}: loopback listener has no TCP address`) + } + onListening(address) + // Snapshot fixtures must never hold the process open past protocol shutdown. + server.unref() + return () => cleanup(server, onCleanup, label) + } catch (cause) { + try { + await cleanup(server, onCleanup, label) + } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], `${label}: setup and cleanup failed`) + } + throw cause + } + }, label) +} diff --git a/snapshots/session/web-fetch/web-fetch-fixture-server.mjs b/snapshots/session/web-fetch/web-fetch-fixture-server.mjs index 2979917043..98f964890c 100644 --- a/snapshots/session/web-fetch/web-fetch-fixture-server.mjs +++ b/snapshots/session/web-fetch/web-fetch-fixture-server.mjs @@ -5,8 +5,8 @@ * and replay therefore exercise fetch and markdown rendering without external * network while retaining the recorded request URL. */ -import { createServer } from 'node:http' import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http' +import { applyLoopbackServerEffect } from '../loopback-fixture-server.mjs' /** Model-visible URL retained by the recorded session. */ const RECORDED_URL = 'http://public.test:43117/menu.html' @@ -36,33 +36,6 @@ const LIMITS = { userAgent: 'deepseek-harness-snapshot/1.0', } -function listen(server) { - return new Promise((resolve, reject) => { - const onError = (error) => { - server.off('error', onError) - reject(error) - } - server.once('error', onError) - try { - server.listen(0, '127.0.0.1', () => { - server.off('error', onError) - resolve(undefined) - }) - } catch (error) { - server.off('error', onError) - reject(error) - } - }) -} - -async function close(server) { - if (!server.listening) return - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve(undefined)) - server.closeAllConnections() - }) -} - /** * Register the deterministic provider and start its loopback server. * @param ctx - Cordis context; the effect disposes the server with the fiber. @@ -89,41 +62,30 @@ export async function apply(ctx) { return { ...result, url: RECORDED_URL } }, }) - await ctx.effect(async () => { - const server = createServer((req, res) => { - if (req.url === '/menu.html') { - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) - res.end(PAGE) - return - } - res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) - res.end('not found') - }) - try { - await listen(server) - const address = server.address() - if (address === null || typeof address === 'string') { - throw new Error('web-fetch-fixture-server: loopback listener has no TCP address') - } - transportUrl = new URL(RECORDED_URL) - transportUrl.port = String(address.port) - // The fixture must never hold the process open past protocol shutdown. - server.unref() - readiness.resolve(undefined) - return async () => { + try { + await applyLoopbackServerEffect(ctx, { + label: 'web-fetch-fixture-server', + requestListener: (req, res) => { + if (req.url === '/menu.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(PAGE) + return + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('not found') + }, + onListening: (address) => { + transportUrl = new URL(RECORDED_URL) + transportUrl.port = String(address.port) + readiness.resolve(undefined) + }, + onCleanup: () => { unregister() - await close(server) - } - } catch (cause) { - startupError = cause - readiness.resolve(undefined) - unregister() - try { - await close(server) - } catch (cleanupError) { - throw new AggregateError([cause, cleanupError], 'web-fetch-fixture-server: setup and cleanup failed') - } - throw cause - } - }, 'web-fetch-fixture-server') + }, + }) + } catch (cause) { + startupError = cause + readiness.resolve(undefined) + throw cause + } } diff --git a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs index ce2087aff6..3c0d9f5d29 100644 --- a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs +++ b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs @@ -1,5 +1,5 @@ /** Deterministic authentication failure for the search endpoint guidance snapshot. */ -import { createServer } from 'node:http' +import { applyLoopbackServerEffect } from '../loopback-fixture-server.mjs' /** Model-visible endpoint retained by the recorded session. */ const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' @@ -8,33 +8,6 @@ const RECORDED_URL = new URL(RECORDED_ENDPOINT) /** Cordis plugin name. */ export const name = 'web-search-error-fixture' -function listen(server) { - return new Promise((resolve, reject) => { - const onError = (error) => { - server.off('error', onError) - reject(error) - } - server.once('error', onError) - try { - server.listen(0, '127.0.0.1', () => { - server.off('error', onError) - resolve(undefined) - }) - } catch (error) { - server.off('error', onError) - reject(error) - } - }) -} - -async function close(server) { - if (!server.listening) return - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve(undefined)) - server.closeAllConnections() - }) -} - function requestUrl(input) { if (typeof input === 'string') return input if (input instanceof URL) return input.href @@ -60,26 +33,12 @@ function transportInput(input, transportEndpoint) { return input } -async function cleanup(server, restoreFetch) { - const errors = [] - try { - restoreFetch() - } catch (error) { - errors.push(error) - } - try { - await close(server) - } catch (error) { - errors.push(error) - } - if (errors.length === 1) throw errors[0] - if (errors.length > 1) throw new AggregateError(errors, 'web-search-error-fixture: cleanup failed') -} - /** Start the local Messages endpoint and stop it with the plugin fiber. */ export async function apply(ctx) { - await ctx.effect(async () => { - const server = createServer((request, response) => { + let restoreFetch = () => {} + await applyLoopbackServerEffect(ctx, { + label: 'web-search-error-fixture', + requestListener: (request, response) => { if (request.method === 'POST' && request.url === '/anthropic/v1/messages') { response.writeHead(401, { 'content-type': 'application/json' }) response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } })) @@ -87,31 +46,19 @@ export async function apply(ctx) { } response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) response.end('not found') - }) - try { - await listen(server) - const address = server.address() - if (address === null || typeof address === 'string') { - throw new Error('web-search-error-fixture: loopback listener has no TCP address') - } + }, + onListening: (address) => { const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` - server.unref() const originalFetch = globalThis.fetch const fixtureFetch = async (input, init) => originalFetch(transportInput(input, transportEndpoint), init) globalThis.fetch = fixtureFetch - return () => cleanup(server, () => { + restoreFetch = () => { if (globalThis.fetch !== fixtureFetch) { throw new Error('web-search-error-fixture: global fetch owner changed before cleanup') } globalThis.fetch = originalFetch - }) - } catch (cause) { - try { - await close(server) - } catch (cleanupError) { - throw new AggregateError([cause, cleanupError], 'web-search-error-fixture: setup and cleanup failed') } - throw cause - } - }, 'web-search-error-fixture') + }, + onCleanup: () => restoreFetch(), + }) } From 3e56eaaa0fd8b6e248c06aca439a8c337e4f143c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 13:04:06 +0800 Subject: [PATCH 070/429] fix(atomic-write): retry transient Windows replacement --- ...-29-windows-atomic-replace-retry.i18n.yaml | 6 ++ ...2026-08-29-windows-atomic-replace-retry.md | 27 +++++ ...6-08-29-windows-atomic-replace-retry.zh.md | 27 +++++ packages/util/atomic-write/README.i18n.yaml | 4 +- packages/util/atomic-write/README.md | 4 +- packages/util/atomic-write/README.zh.md | 4 +- packages/util/atomic-write/src/index.ts | 35 +++++- .../atomic-write/tests/atomic-write.spec.ts | 101 ++++++++++++++++-- 8 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.i18n.yaml new file mode 100644 index 0000000000..694e117443 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.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-29-windows-atomic-replace-retry.md +2026-08-29-windows-atomic-replace-retry.md: 4db5de6403be7ec39a1568a11d8877cba1ed5838 +2026-08-29-windows-atomic-replace-retry.zh.md: 0138727ac0fe12af51b5a383b60859977300353c diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md b/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md new file mode 100644 index 0000000000..4db5de6403 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md @@ -0,0 +1,27 @@ +# Agent Note: Retry transient Windows atomic replacements + +Status: implemented + +English | [中文](2026-08-29-windows-atomic-replace-retry.zh.md) + +## Problem + +Windows can temporarily reject a rename that replaces an existing file with `EACCES`, `EBUSY`, or `EPERM` while another system component holds the target. The cross-process writer lock orders cooperating application writers but cannot release that external handle, so treating the first error as permanent makes an otherwise valid settings or credentials update fail nondeterministically. + +## Decision + +`writeFileAtomic` owns replacement retry because every file-backed store needs the same guarantee. On Windows only, it retries `EACCES`, `EBUSY`, and `EPERM` up to eight times with exponential delays from 20 to 200 milliseconds. The same fully written temporary sibling remains the rename source throughout, and a caller-held writer lock remains held until `writeFileAtomic` settles. + +Other error codes and other operating systems fail immediately. Exhausting the retry budget rethrows the final filesystem error after removing the temporary sibling; the existing target remains unchanged because no attempt deletes or truncates it. + +## Alternatives considered + +**Retry the credentials mutation.** A consumer-level retry would leave settings and future stores exposed, and replaying a read-modify-write operation can repeat work outside the atomic replacement. The shared primitive is the narrow owner of replacement-only retry. + +**Delete the target before rename.** Removing the target can make readers observe an absent file and forfeits atomic replacement, so it cannot be a recovery step. + +**Retry indefinitely.** A permanent permission error would then hang the writer and any lock contender. A bounded delay absorbs transient file use while preserving a predictable failure outcome. + +## Consequences + +A transient Windows handle can delay one replacement by at most 1.1 seconds before the final attempt fails. During that interval readers continue to see the complete old target, and success still consists of one atomic rename. Regression tests inject every retried code, permanent and non-Windows failures, and retry exhaustion; they observe rename attempts and advance fake timers rather than depending on wall-clock sleeps. diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.zh.md b/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.zh.md new file mode 100644 index 0000000000..0138727ac0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 重试 Windows 上的瞬时原子替换失败 + +Status: implemented + +[English](2026-08-29-windows-atomic-replace-retry.md) | 中文 + +## 问题 + +当另一个系统组件持有目标文件时,Windows 可能以 `EACCES`、`EBUSY` 或 `EPERM` 暂时拒绝替换已有文件的 rename。跨进程写锁能够排序应用内互相协作的写入方,却无法释放该外部句柄,因此把第一次错误当作永久失败会让本来有效的设置或凭据更新随机失败。 + +## 决策 + +`writeFileAtomic` 负责替换重试,因为每个文件型存储都需要相同保证。它仅在 Windows 上重试 `EACCES`、`EBUSY` 与 `EPERM`,最多八次,延迟从 20 毫秒指数增长至 200 毫秒。整个过程中,同一份已经完整写入的临时兄弟文件始终作为 rename 来源;调用方持有的写锁也会保持到 `writeFileAtomic` 结束。 + +其他错误码和其他操作系统会立即失败。重试预算耗尽后,函数移除临时兄弟文件并重新抛出最后一个文件系统错误;由于任何尝试都不会删除或截断现有目标,目标内容保持不变。 + +## 考虑过的替代方案 + +**重试凭据变更。** 消费方级重试仍会让设置和未来存储暴露于同一问题,而且重放一次读-修改-写操作可能重复原子替换之外的工作。共享原语是只负责替换重试的最窄所有者。 + +**在 rename 前删除目标。** 删除目标会让读取方观察到文件缺失,并放弃原子替换,因此不能作为恢复步骤。 + +**无限重试。** 永久权限错误会由此挂住写入方与所有锁竞争者。有界延迟可以吸收瞬时文件占用,同时保留可预测的失败结果。 + +## 后果 + +一个瞬时 Windows 句柄最多会让单次替换多等待 1.1 秒,随后最终尝试失败。在此期间,读取方继续看到完整的旧目标;成功仍由一次原子 rename 完成。回归测试注入每种可重试错误、永久错误、非 Windows 错误与重试耗尽,并观察 rename 尝试和推进伪时钟,而不依赖真实时间 sleep。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index 84dba6fd7b..ffb53e092a 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/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/util/atomic-write/README.md -README.md: 22806b539faaa7668c37d863c20ffced2576bde6 -README.zh.md: 1468f06aa4d46d9ea7c471bbb045314b67ae595e +README.md: 69daf671ba9d1269643533a6bb6e64462b8bee05 +README.zh.md: 8a8613c673c4d12634c686cda7f2ced9957492b2 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 22806b539f..69daf671ba 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -36,7 +36,7 @@ declare const text: string await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` -Parent directories are created as needed, and readers observe either the old or the new complete content. On any failure the temporary file is removed and the failure is rethrown, so a failed replacement leaves the target untouched. +Parent directories are created as needed, and readers observe either the old or the new complete content. On Windows, transient replacement interference reported as `EACCES`, `EBUSY`, or `EPERM` is retried for a bounded interval; any remaining failure removes the temporary file and leaves the target untouched. ### Coordinating writers @@ -79,7 +79,7 @@ The package is built on one separation: the atomic commit owns the swap, and the ### Write path -`writeFileAtomic` writes a random-suffix sibling opened with exclusive create (`wx`), then renames it over the target. The exclusive open refuses to follow a symlink planted at a guessable temp path; the same-directory sibling keeps the rename on one filesystem; and the rename replaces a symlinked target itself instead of writing through to its referent. +`writeFileAtomic` writes a random-suffix sibling opened with exclusive create (`wx`), then renames it over the target. The exclusive open refuses to follow a symlink planted at a guessable temp path; the same-directory sibling keeps the rename on one filesystem; and the rename replaces a symlinked target itself instead of writing through to its referent. A Windows retry keeps the same complete sibling and uses bounded exponential backoff, so temporary use of the target by software outside the cooperative writer lock cannot turn a safe replacement into an immediate failure; the [retry decision](../../../.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md) owns the rationale and rejected alternatives. `withFileLock` creates a `.lock` sibling with `wx`. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. The lock records its creator's PID and is removed by the holder in a `finally`; contention backs off exponentially and fails when the per-call `waitMs` deadline (default two seconds) passes. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 1468f06aa4..8a8613c673 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -36,7 +36,7 @@ declare const text: string await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` -父目录会按需创建,读取方只会观察到旧内容或完整的新内容。任何失败都会移除临时文件并重新抛出该失败,因此一次失败的替换不会改动目标文件。 +父目录会按需创建,读取方只会观察到旧内容或完整的新内容。在 Windows 上,报告为 `EACCES`、`EBUSY` 或 `EPERM` 的瞬时替换干扰会在有界时间内重试;任何剩余失败都会移除临时文件,并保持目标文件不变。 ### 协调写入方 @@ -79,7 +79,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { ### 写入路径 -`writeFileAtomic` 先以独占创建(`wx`)打开一个随机后缀的同级文件并写入内容,然后 rename 到目标上。独占打开拒绝跟随预先埋在可猜测临时路径上的符号链接;同目录兄弟文件保证 rename 落在同一文件系统上;rename 替换的是符号链接目标本身,绝不写穿到其指向的文件。 +`writeFileAtomic` 先以独占创建(`wx`)打开一个随机后缀的同级文件并写入内容,然后 rename 到目标上。独占打开拒绝跟随预先埋在可猜测临时路径上的符号链接;同目录兄弟文件保证 rename 落在同一文件系统上;rename 替换的是符号链接目标本身,绝不写穿到其指向的文件。Windows 重试会保留同一份完整的兄弟文件,并采用有界指数退避,因此协作式写锁之外的软件瞬时占用目标时,不会让安全替换立即失败;[重试决策](../../../.agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.zh.md)记录了理由与被拒绝的替代方案。 `withFileLock` 以 `wx` 创建 `.lock` 同级文件。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。锁记录创建者的 PID,由持有者在 `finally` 中移除;竞争按指数退避,在每次调用声明的 `waitMs` 期限(默认两秒)过后失败。 diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index 3e5764a329..467c3c29b5 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -14,6 +14,33 @@ import { randomBytes } from 'node:crypto' import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +const WINDOWS_TRANSIENT_RENAME_ERRORS: ReadonlySet = new Set(['EACCES', 'EBUSY', 'EPERM']) +const WINDOWS_RENAME_RETRY_INITIAL_MS = 20 +const WINDOWS_RENAME_RETRY_MAX_MS = 200 +const WINDOWS_RENAME_RETRY_LIMIT = 8 + +/** Whether Windows reported temporary interference with an atomic replacement. */ +function isTransientWindowsRenameError(error: unknown): boolean { + if (process.platform !== 'win32') return false + return WINDOWS_TRANSIENT_RENAME_ERRORS.has((error as NodeJS.ErrnoException | null)?.code ?? '') +} + +/** Replace the target after bounded retries for transient Windows interference. */ +async function renameAtomicTemp(temp: string, filename: string): Promise { + let delay = WINDOWS_RENAME_RETRY_INITIAL_MS + for (let retries = 0;; retries += 1) { + try { + await rename(temp, filename) + return + } catch (error) { + if (!isTransientWindowsRenameError(error)) throw error + if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS) + } +} + /** * Filesystem options for {@link writeFileAtomic}; `mode` is required so the * permission decision stays visible at every call site. @@ -40,8 +67,10 @@ export interface WriteFileAtomicOptions { * rename, so replacing a wider-permission file narrows it without a chmod * race. The rename also replaces a symlinked target itself instead of writing * through to its referent, and the same-directory sibling keeps the rename on - * one filesystem. On any failure the temp file is removed and the failure - * rethrown. Crash durability (fsync) is out of scope. + * one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`, + * and `EPERM` failures for a bounded interval while the complete temp file + * remains the rename source. On any remaining failure the temp file is + * removed and the failure rethrown. Crash durability (fsync) is out of scope. * @param filename - final path receiving the content. * @param content - complete next file content. * @param options - permission bits for the replacement inode. @@ -56,7 +85,7 @@ export async function writeFileAtomic(filename: string, content: string, options const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` try { await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) - await rename(temp, filename) + await renameAtomicTemp(temp, filename) } catch (error) { await rm(temp, { force: true }) throw error diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 683abe51bc..ff3a2a4e6a 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -1,15 +1,28 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { withFileLock, writeFileAtomic } from '../src/index.ts' -const state = vi.hoisted(() => ({ failLockCreateWithEPERM: false })) +const state = vi.hoisted(() => ({ + failLockCreateWithEPERM: false, + renameAttempts: 0, + renameFailures: [] as string[], +})) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + rename: (async (...args: Parameters) => { + state.renameAttempts += 1 + const code = state.renameFailures.shift() + if (code !== undefined) { + if (code === 'NO_CODE') throw new Error('injected rename failure without a code') + throw Object.assign(new Error(`${code}: injected rename failure`), { code }) + } + return actual.rename(...args) + }), writeFile: (async (path: unknown, ...rest: never[]) => { if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) { state.failLockCreateWithEPERM = false @@ -20,12 +33,26 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) -afterEach(() => { +const scratchDirs: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + vi.restoreAllMocks() state.failLockCreateWithEPERM = false + state.renameAttempts = 0 + state.renameFailures.length = 0 + await Promise.all(scratchDirs.splice(0).map(dir => rm(dir, { + force: true, + maxRetries: 10, + recursive: true, + retryDelay: 20, + }))) }) async function scratch(): Promise { - return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) + scratchDirs.push(dir) + return dir } /** Resolve once the lockfile exists, so contention is measured against a held lock. */ @@ -44,9 +71,12 @@ describe('writeFileAtomic', () => { it('creates the file and its parents with exactly the stated mode', async () => { const dir = await scratch() const target = join(dir, 'nested', 'deep', 'doc.yaml') - await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) + await writeFileAtomic(target, 'a: 1\n', { dirMode: 0o700, mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('a: 1\n') - if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect((await stat(dirname(target))).mode & 0o777).toBe(0o700) + expect((await stat(target)).mode & 0o777).toBe(0o600) + } }) it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { @@ -70,13 +100,62 @@ describe('writeFileAtomic', () => { expect(await readFile(victim, 'utf8')).toBe('victim-content') }) - it('leaves no temp sibling and rethrows when the rename fails', async () => { + it('retries transient Windows rename interference and commits the replacement', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.useFakeTimers() const dir = await scratch() - const target = join(dir, 'occupied') - await mkdir(target) - await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() + const target = join(dir, 'document') + await writeFile(target, 'old') + state.renameFailures.push('EACCES', 'EBUSY', 'EPERM') + + const replacement = writeFileAtomic(target, 'new', { mode: 0o600 }) + await vi.waitFor(() => { expect(state.renameAttempts).toBeGreaterThan(0) }) + await vi.runAllTimersAsync() + await replacement + + expect(state.renameAttempts).toBe(4) + expect(await readFile(target, 'utf8')).toBe('new') expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) }) + + it('leaves no temp sibling after bounded Windows rename retries expire', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.useFakeTimers() + const dir = await scratch() + const target = join(dir, 'document') + await writeFile(target, 'old') + state.renameFailures.push(...Array.from({ length: 9 }, () => 'EPERM')) + + const replacement = writeFileAtomic(target, 'new', { mode: 0o600 }) + await vi.waitFor(() => { expect(state.renameAttempts).toBeGreaterThan(0) }) + await vi.runAllTimersAsync() + await expect(replacement).rejects.toMatchObject({ code: 'EPERM' }) + + expect(state.renameAttempts).toBe(9) + expect(await readFile(target, 'utf8')).toBe('old') + expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) + }) + + it('does not retry a Windows rename failure without a transient code', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const dir = await scratch() + const target = join(dir, 'document') + state.renameFailures.push('NO_CODE') + + await expect(writeFileAtomic(target, 'new', { mode: 0o600 })).rejects.toThrow(/without a code/) + expect(state.renameAttempts).toBe(1) + expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) + }) + + it('does not retry rename permission failures outside Windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const dir = await scratch() + const target = join(dir, 'document') + state.renameFailures.push('EPERM') + + await expect(writeFileAtomic(target, 'new', { mode: 0o600 })).rejects.toMatchObject({ code: 'EPERM' }) + expect(state.renameAttempts).toBe(1) + }) }) describe('withFileLock', () => { From 596a13d1cb73ee797efd22b19fb12d301031e00f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 13:54:59 +0800 Subject: [PATCH 071/429] docs(testing): add platform-semantics and lane-budget rules to the skill Two failure classes the repository paid for are not covered by the isolation, synchronization, and teardown rules already in the skill. A value the operating system owns is not guaranteed to return as written. A test may write one back only where the assertion tolerates that write-back failing; where the assertion depends on it, the expected value comes from a fresh read. NTFS truncating a fractional-millisecond mtime and Windows folding environment variable name case are the two instances seen so far. A describe or case timeout overrides the runner's --testTimeout rather than yielding to it, so a value below the lane budget lowers what CI granted, while the same literal reads as a widening where the host default is smaller. The hook budget travels with the test budget, and a case asserting a timeout keeps its outer wait far larger than the timeout under test. Restoring a granted budget, or sizing a bounded retry to measured contention, is named as distinct from the masking fixes the skill rejects. The diagnosis reference gains the platform differences under its platform class, a classification path for self-hosted pools that expose no host metrics, and the stopping rule for a signature no available host can reproduce. --- ...-08-28-ci-test-reliability-skill.i18n.yaml | 4 ++-- .../2026-08-28-ci-test-reliability-skill.md | 2 ++ ...2026-08-28-ci-test-reliability-skill.zh.md | 2 ++ .../skills/dsh-ci-test-reliability/SKILL.md | 22 +++++++++++++++++++ .../references/ci-flake-diagnosis.md | 6 +++-- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.i18n.yaml b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.i18n.yaml index 473728433a..6d33252c51 100644 --- a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.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-28-ci-test-reliability-skill.md -2026-08-28-ci-test-reliability-skill.md: 5edf2aa769047cb5d4052a1338f2cc3886d9e0f9 -2026-08-28-ci-test-reliability-skill.zh.md: fd1a6d584f3c4686761f8c94ba99ee0d1c9b0233 +2026-08-28-ci-test-reliability-skill.md: 1c8e0389dfa6e3eb3a6e04e994e6400d19a673ac +2026-08-28-ci-test-reliability-skill.zh.md: ef36eab497bee874cd117488ddf4895c7edd0ed1 diff --git a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md index 5edf2aa769..1c8e0389df 100644 --- a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md +++ b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.md @@ -16,6 +16,8 @@ The testing policy owns test tiers, defensive patterns own runtime lifecycle rul The skill requires agents to model concurrency beyond one Vitest process, allocate live resources atomically, separate stable fixture identities from ephemeral transport addresses, synchronize on observable state, restore global mutations exactly, and await teardown to quiescence. Regression evidence matches the owned risk: negative controls for guards, deterministic barriers for races, concurrent independent processes for host-resource isolation, and external observations instead of component self-reports. +Two rules cover the failures the repository has actually paid for. A value the operating system owns is not guaranteed to return as written, so a test may write one back only where the assertion tolerates that write-back failing; where the assertion depends on it, the expected value comes from a fresh read. And a suite timeout overrides the runner flag rather than yielding to it, so a suite bound by process creation takes the lane budget, raises the hook budget with it, and keeps an outer wait far larger than any timeout under test. Restoring a granted budget or sizing a bounded retry to measured contention is therefore not a masking fix. + The diagnosis-only workflow lives in a separate reference so ordinary authoring does not load Actions triage procedure. It compares passing and failing evidence before classifying host collisions, incomplete lifecycle, global contamination, load-sensitive synchronization, platform or entry-path failures, product races, provider transience, or runner infrastructure. [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) conditionally consults the reliability skill before selecting commands, while [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) applies it when reviewing risky tests. Command selection and general PR review remain with those existing skills. diff --git a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md index fd1a6d584f..ef36eab497 100644 --- a/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md +++ b/.agents/notes/implemented/testing/2026-08-28-ci-test-reliability-skill.zh.md @@ -16,6 +16,8 @@ DeepSeek Harness 会在并发的 Vitest 文件、worker 进程、仓库 gate 与 该 Skill 要求 agent 建模单个 Vitest 进程之外的并发,原子分配实时资源,把稳定 fixture 标识与临时传输地址分开,按可观察状态同步,精确恢复全局变更,并等待 teardown 达到静止状态。回归证据与所持有的风险匹配:guard 使用负向控制,竞态使用确定性 barrier,宿主机资源隔离使用并发独立进程,并以外部观察代替组件自述。 +另有两条规则覆盖仓库已经付出过代价的失败。操作系统拥有的值不保证按写入的样子返回,因此只有在断言容忍写回失败时,测试才可以把它写回去;断言依赖写回成功时,期望值取自重新读取。以及套件级 timeout 覆盖而不是让位于 runner 的 flag,因此受进程创建约束的套件取 lane 预算、连同 hook 预算一起抬高,并让外层等待远大于任何被测超时。据此,恢复已被授予的预算、或按实测争抢标定一个有界重试,都不属于掩盖式修复。 + 仅用于诊断的流程放在单独 reference 中,因此普通编写任务不会加载 Actions 分诊步骤。它会先比较成功与失败证据,再对宿主机冲突、未完成生命周期、全局状态污染、负载敏感同步、平台或入口路径失败、产品竞态、provider 瞬时故障或 runner 基础设施进行分类。 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 在选择命令前按条件引用可靠性 Skill,[dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 则在 review 高风险测试时应用它。命令选择与通用 PR review 仍由这些现有 Skill 负责。 diff --git a/.agents/skills/dsh-ci-test-reliability/SKILL.md b/.agents/skills/dsh-ci-test-reliability/SKILL.md index 7483f13fb0..d9fd0f1700 100644 --- a/.agents/skills/dsh-ci-test-reliability/SKILL.md +++ b/.agents/skills/dsh-ci-test-reliability/SKILL.md @@ -53,6 +53,25 @@ Prefer an injected dependency or instance-local adapter. When mutation is requir - keep an `afterEach` fallback when failure before the local `finally` is plausible; - intercept the narrowest exact request or call that the fixture owns. +## Respect platform-owned semantics + +CI runs the same suite on Windows and on POSIX hosts, and a value the operating system owns does not always come back the way a test wrote it. + +- Writing a value back is safe only when the assertion tolerates the write-back failing. Restoring a file's `mtime` to prove that a fingerprint invalidates anyway holds everywhere; restoring it to prove that a record stays valid assumes a lossless round trip, which NTFS's 100-nanosecond ticks do not give a fractional millisecond. When the assertion depends on the restoration, take the expected value from a fresh read rather than from the remembered one. +- Windows matches environment variable names case-insensitively, so a fixture seeding `http_proxy` and `HTTP_PROXY` as separate keys holds one entry there. +- Windows releases file handles asynchronously, so a rename or removal that completes at once on a POSIX host needs a bounded retry sized to the observed contention. +- Windows has no POSIX permission or signal semantics. A case that depends on them takes an explicit platform skip naming the reason, rather than an assertion weakened everywhere. + +Prefer an observation that holds on every platform. When a case genuinely cannot, exclude it on that platform explicitly. + +## Budget timeouts against the lane + +A `describe` or case timeout overrides the runner's `--testTimeout` instead of yielding to it, so a value below the lane's budget lowers what CI already granted — and the same literal reads as a widening on a host whose default is smaller. A suite bound by process creation takes the lane budget; a tighter value carries the reason it is tighter. + +Raise the hook budget with the test budget. Setup and teardown pay the same contention, so lifting only the case budget moves a contended failure into `afterEach`. + +Where a timeout is the subject, keep the outer wait far larger than the timeout under test. A case proving that a 20 ms deadline fires must not race the harness's own wait, or load decides which deadline reports first. + ## Synchronize on state A fixed sleep is not evidence that setup completed or cleanup settled. @@ -75,6 +94,7 @@ Calling `abort()`, `close()`, or `kill()` without awaiting the owned completion - For a new static or corpus guard, temporarily introduce the rejected case and observe the intended failure. - For a race, use barriers to prove overlap; repeated execution alone is not a race test. - For ports, sockets, shared paths, subprocesses, or other host resources, run independent test processes concurrently when cross-process isolation is part of the fix. +- Where a fixture spawns with its own deadline, assert that no signal or timeout ended the child before asserting its exit status, so a killed child reports as a timeout instead of as a status mismatch. - Verify external state, events, files, logs, exits, or disposal instead of trusting the component's self-report. Stress runs supplement a deterministic regression; they do not replace one. @@ -93,6 +113,8 @@ Do not present these as root-cause fixes for deterministic local tests: Retries remain valid for documented transient external-provider tests under the real-API policy. Keep that exception at the external boundary. +Restoring a budget is not masking. Raising a suite to the lane budget it already had, or sizing a bounded retry to the contention actually measured on the runner, names the awaited work and returns what the lane granted; neither invents headroom around an unexamined wait. + ## Diagnose existing flakes For an existing probabilistic CI failure, read [the CI flake diagnosis workflow](references/ci-flake-diagnosis.md). A diagnosis-only request remains read-only: report the cause and evidence unless the user also asks for a fix. diff --git a/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md b/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md index 101ad90cca..9cdfb61972 100644 --- a/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md +++ b/.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md @@ -18,10 +18,10 @@ Classify from recorded evidence, not from the eventual fix: - **Incomplete lifecycle:** teardown returns before children, workers, streams, servers, or callbacks reach quiescence; later output or mutations appear in another test. - **Process-global contamination:** outcome depends on test order or leaked `process.env`, `cwd`, fake timers, globals, mocks, locale, or module state. - **Load-sensitive synchronization:** a sleep, polling interval, or assumed event-loop turn substitutes for observable readiness or completion. -- **Platform or entry-path mismatch:** the failure consistently follows an operating system, shell, filesystem rule, source/build mode, or executable entry. +- **Platform or entry-path mismatch:** the failure consistently follows an operating system, shell, filesystem rule, source/build mode, or executable entry. Timestamp precision, environment variable name case, handle-release timing, and permission semantics all differ between Windows and POSIX hosts, so a case passing on macOS says nothing about the Windows lane. - **Product concurrency defect:** the test controls its resources, reproduces deterministically with explicit overlap, and exposes a race in shipped behavior. - **External-provider transience:** the failure is owned by a live API or network boundary and matches its documented retry policy. -- **Runner infrastructure:** checkout, dependency download, disk, host process, or runner service fails independently of the test command. Require direct runner evidence before assigning this class. +- **Runner infrastructure:** checkout, dependency download, disk, host process, or runner service fails independently of the test command. Require direct runner evidence before assigning this class. Where a self-hosted pool exposes no host metrics, say so and classify from what the logs do carry: one signature repeating across unrelated branches on one pool is evidence of shared-host contention even when the host cannot be inspected. If evidence supports more than one independent fact, report each one. Do not collapse a timeout, signal, exit code, and assertion into a single inferred outcome. @@ -37,6 +37,8 @@ Start with the owning test file or focused test name. Increase concurrency only Match the active Vitest config, environment knobs, source/build mode, and platform. Do not lower a production timeout or add random load merely to manufacture a different failure. +Where the signature belongs to a platform the available host cannot run, the ladder stops at the last reachable rung. Record that limit rather than substituting a passing run on another platform, then use CI as the reproduction, changing one suspected owner per run so the result stays attributable. + For a suspected race, replace probabilistic timing with a barrier at the contested transition. For a suspected host collision, prove simultaneous acquisition of the same identifier or prove that atomic unique allocation removes the conflict. ## Fix at the owner From de256e8bc134a9063b61a4b9930909b407ba4eb4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:42 +0800 Subject: [PATCH 072/429] feat: enforce published dependency policy --- package.json | 3 + .../benchmark-next-package-dependency.spec.ts | 114 +++++ scripts/benchmark-next-package-dependency.ts | 271 ++++++++++ scripts/benchmark-npm-resolution.spec.ts | 84 +++ scripts/benchmark-npm-resolution.ts | 417 +++++++++++++++ scripts/package-dependency-policy.ts | 52 ++ scripts/package-invariants.spec.ts | 62 ++- scripts/package-invariants.ts | 23 +- scripts/run-gates.spec.ts | 11 +- scripts/run-gates.ts | 2 + scripts/verify-client-packages.spec.ts | 162 +----- scripts/verify-client-packages.ts | 314 +----------- scripts/verify-package-dependencies.spec.ts | 260 ++++++++++ scripts/verify-package-dependencies.ts | 479 ++++++++++++++++++ 14 files changed, 1804 insertions(+), 450 deletions(-) create mode 100644 scripts/benchmark-next-package-dependency.spec.ts create mode 100644 scripts/benchmark-next-package-dependency.ts create mode 100644 scripts/benchmark-npm-resolution.spec.ts create mode 100644 scripts/benchmark-npm-resolution.ts create mode 100644 scripts/package-dependency-policy.ts create mode 100644 scripts/verify-package-dependencies.spec.ts create mode 100644 scripts/verify-package-dependencies.ts diff --git a/package.json b/package.json index 9587f220d0..2ec0a09049 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "test:web:perf": "npm run build && npm run test:web:perf:built", "test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts", "test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts", + "benchmark:npm-resolution": "tsx scripts/benchmark-npm-resolution.ts", + "benchmark:npm-resolution:next": "tsx scripts/benchmark-next-package-dependency.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", @@ -104,6 +106,7 @@ "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", + "verify-package-dependencies": "tsx scripts/verify-package-dependencies.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", diff --git a/scripts/benchmark-next-package-dependency.spec.ts b/scripts/benchmark-next-package-dependency.spec.ts new file mode 100644 index 0000000000..9248a3288e --- /dev/null +++ b/scripts/benchmark-next-package-dependency.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + applyFactsToRegistry, + discoverBenchmarkCandidates, + parseNextPackageBenchmarkOptions, + type MutableRegistryManifest, +} from './benchmark-next-package-dependency.ts' +import type { + PackageDependencyFacts, + PackageDependencyManifest, + WorkspacePackageManifest, +} from './verify-package-dependencies.ts' +import type { RegistryIndex } from './benchmark-npm-resolution.ts' + +describe('next package benchmark options', () => { + it('parses candidate and repetition controls', () => { + expect(parseNextPackageBenchmarkOptions([ + '--', + '--candidates=@f/a,@f/b', + '--runs=2', + '--finalist-runs=4', + '--finalists=3', + '--jobs=6', + '--timeout-ms=9000', + ])).toEqual({ + candidates: ['@f/a', '@f/b'], + coarseRuns: 2, + finalistRuns: 4, + finalists: 3, + jobs: 6, + timeoutMs: 9000, + }) + }) + + it('rejects invalid positive integers', () => { + expect(() => parseNextPackageBenchmarkOptions(['--jobs=0'])).toThrow('--jobs must be a positive integer') + }) +}) + +describe('next package benchmark graph', () => { + it('applies a source-derived candidate without changing the filesystem', () => { + const manifest: PackageDependencyManifest & { version: string } = { + name: '@f/probe', + version: '1.0.0', + peerDependencies: { + '@deepseek-ai/cordis': 'workspace:^', + '@f/runtime': 'workspace:^', + '@f/types': 'workspace:^', + }, + devDependencies: { + '@deepseek-ai/cordis': 'workspace:^', + '@f/runtime': 'workspace:^', + '@f/types': 'workspace:^', + }, + } + const facts: PackageDependencyFacts = { + manifestPath: 'packages/g/probe/package.json', + role: 'configured-host', + manifest, + workspaceNames: new Set(['@deepseek-ai/cordis', '@f/probe', '@f/runtime', '@f/types']), + allSourceUses: new Map([ + ['@f/runtime', ['packages/g/probe/src/index.ts']], + ['@f/types', ['packages/g/probe/src/types.ts']], + ]), + hostRuntimeSourceUses: new Map([['@f/runtime', ['packages/g/probe/src/index.ts']]]), + clientInject: new Set(), + } + const index = new Map>([ + ['@f/probe', new Map([['1.0.0', structuredClone(manifest) as MutableRegistryManifest]])], + ]) + applyFactsToRegistry(index, facts, new Map([ + ['@deepseek-ai/cordis', '4.0.1'], + ['@f/probe', '1.0.0'], + ['@f/runtime', '2.0.0'], + ['@f/types', '3.0.0'], + ])) + + expect(index.get('@f/probe')?.get('1.0.0')).toMatchObject({ + dependencies: { '@f/runtime': '^2.0.0' }, + peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' }, + }) + expect(index.get('@f/probe')?.get('1.0.0')?.dependencies).not.toHaveProperty('@f/types') + }) + + it('finds reachable unconfigured packages with non-Cordis peers', () => { + const index = new Map([ + ['@deepseek-ai/dsh', new Map([['1.0.0', { + name: '@deepseek-ai/dsh', version: '1.0.0', dependencies: { '@f/a': '^1.0.0', '@f/b': '^1.0.0' }, + }]])], + ['@f/a', new Map([['1.0.0', { + name: '@f/a', version: '1.0.0', peerDependencies: { '@f/runtime': '^1.0.0' }, + }]])], + ['@f/b', new Map([['1.0.0', { + name: '@f/b', version: '1.0.0', peerDependencies: { '@deepseek-ai/cordis': '^4.0.0' }, + }]])], + ['@f/runtime', new Map([['1.0.0', { name: '@f/runtime', version: '1.0.0' }]])], + ]) as RegistryIndex + const release = new Map([ + ['@f/a', { + name: '@f/a', dir: 'packages/g/a', manifestPath: 'packages/g/a/package.json', manifest: { name: '@f/a' }, + }], + ['@f/b', { + name: '@f/b', dir: 'packages/g/b', manifestPath: 'packages/g/b/package.json', manifest: { name: '@f/b' }, + }], + ]) + + expect(discoverBenchmarkCandidates( + index, + new Map([['@deepseek-ai/dsh', '1.0.0'], ['@f/a', '1.0.0'], ['@f/b', '1.0.0']]), + release, + new Set(), + )).toEqual(['@f/a']) + }) +}) diff --git a/scripts/benchmark-next-package-dependency.ts b/scripts/benchmark-next-package-dependency.ts new file mode 100644 index 0000000000..545045de6f --- /dev/null +++ b/scripts/benchmark-next-package-dependency.ts @@ -0,0 +1,271 @@ +/** Benchmark which additional Host package most reduces npm peer resolution. */ + +import { availableParallelism } from 'node:os' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { + benchmarkNpmResolution, + buildRegistryIndex, + parsePositiveIntegerOption, + publishWorkspaceRange, + type RegistryIndex, +} from './benchmark-npm-resolution.ts' +import { + readPackageDependencyFacts, + readPackageDependencyState, + readWorkspacePackageManifests, + repairPackageDependencyManifest, + type PackageDependencyFacts, + type WorkspacePackageManifest, +} from './verify-package-dependencies.ts' + +const TARGET_PACKAGE = '@deepseek-ai/dsh' +const CORDIS = '@deepseek-ai/cordis' + +interface Options { + readonly candidates?: readonly string[] + readonly coarseRuns: number + readonly finalistRuns: number + readonly finalists: number + readonly jobs: number + readonly timeoutMs: number +} + +export interface MutableRegistryManifest { + name: string + version: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +interface Measurement { + readonly package: string + readonly seconds: readonly number[] + readonly medianSeconds: number +} + +/** Parse benchmark selection and repetition options. */ +export function parseNextPackageBenchmarkOptions(args: readonly string[]): Options { + const normalized = args[0] === '--' ? args.slice(1) : args + const { values } = parseArgs({ + args: [...normalized], + options: { + candidates: { type: 'string' }, + runs: { type: 'string' }, + 'finalist-runs': { type: 'string' }, + finalists: { type: 'string' }, + jobs: { type: 'string' }, + 'timeout-ms': { type: 'string' }, + }, + allowPositionals: false, + }) + return { + ...(values.candidates === undefined + ? {} + : { candidates: values.candidates.split(',').filter(Boolean) }), + coarseRuns: parsePositiveIntegerOption(values.runs, 1, '--runs'), + finalistRuns: parsePositiveIntegerOption(values['finalist-runs'], 3, '--finalist-runs'), + finalists: parsePositiveIntegerOption(values.finalists, 5, '--finalists'), + jobs: parsePositiveIntegerOption(values.jobs, Math.min(8, availableParallelism()), '--jobs'), + timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], 120_000, '--timeout-ms'), + } +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 + : sorted[middle] ?? 0 +} + +function cloneIndex(index: RegistryIndex): Map> { + return new Map([...index].map(([name, versions]) => [ + name, + new Map([...versions].map(([version, manifest]) => [ + version, + structuredClone(manifest) as MutableRegistryManifest, + ])), + ])) +} + +function publishedSection( + values: Readonly> | undefined, + workspaceVersions: ReadonlyMap, +): Record | undefined { + if (values === undefined) return undefined + return Object.fromEntries(Object.entries(values).map(([name, range]) => { + const version = workspaceVersions.get(name) + return [name, version === undefined ? range : publishWorkspaceRange(range, version)] + })) +} + +/** Apply one source-derived policy result to an in-memory registry manifest. */ +export function applyFactsToRegistry( + index: Map>, + facts: PackageDependencyFacts, + workspaceVersions: ReadonlyMap, +): void { + const source = structuredClone(facts.manifest) + repairPackageDependencyManifest({ ...facts, manifest: source }) + const version = workspaceVersions.get(source.name ?? '') + const target = version === undefined ? undefined : index.get(source.name ?? '')?.get(version) + if (target === undefined) throw new Error(`local registry has no ${source.name ?? 'unnamed package'}@${version ?? 'unknown'}`) + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) { + const values = publishedSection(source[field], workspaceVersions) + if (values !== undefined) target[field] = values + else if (field === 'dependencies') delete target.dependencies + else if (field === 'optionalDependencies') delete target.optionalDependencies + else delete target.peerDependencies + } + if (source.peerDependenciesMeta === undefined) delete target.peerDependenciesMeta + else target.peerDependenciesMeta = structuredClone(source.peerDependenciesMeta) as Record +} + +function currentVersion(pkg: WorkspacePackageManifest): string { + const version = pkg.manifest.version + if (typeof version !== 'string') throw new Error(`${pkg.manifestPath}: missing package version`) + return version +} + +/** Find reachable Host candidates whose published manifests still carry non-Cordis peers. */ +export function discoverBenchmarkCandidates( + index: RegistryIndex, + workspaceVersions: ReadonlyMap, + releasePackages: ReadonlyMap, + policyPackages: ReadonlySet, +): string[] { + const reached = new Set() + const queue = [TARGET_PACKAGE] + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const name = queue[cursor] + if (name === undefined || reached.has(name)) continue + const version = workspaceVersions.get(name) + const manifest = version === undefined ? undefined : index.get(name)?.get(version) + if (manifest === undefined) continue + reached.add(name) + const installed = { + ...manifest.dependencies, + ...manifest.optionalDependencies, + ...Object.fromEntries(Object.entries(manifest.peerDependencies ?? {}) + .filter(([peer]) => (manifest.peerDependenciesMeta?.[peer] as { optional?: boolean } | undefined)?.optional !== true)), + } + for (const dependency of Object.keys(installed).sort()) { + if (!reached.has(dependency)) queue.push(dependency) + } + } + return [...reached].filter((name) => { + if (policyPackages.has(name) || !releasePackages.has(name)) return false + const version = workspaceVersions.get(name) + const manifest = version === undefined ? undefined : index.get(name)?.get(version) + return Object.keys(manifest?.peerDependencies ?? {}).some(peer => peer !== CORDIS) + }).sort() +} + +async function measure( + index: RegistryIndex, + targetVersion: string, + runs: number, + timeoutMs: number, +): Promise { + const seconds: number[] = [] + for (let run = 0; run < runs; run += 1) { + const result = await benchmarkNpmResolution(index, targetVersion, timeoutMs) + if (result.archiveRequests > 0) throw new Error('metadata-only benchmark requested package archives') + seconds.push(Number((result.durationMs / 1000).toFixed(2))) + } + return seconds +} + +async function mapConcurrent( + values: readonly T[], + jobs: number, + operation: (value: T) => Promise, +): Promise { + const results: R[] = [] + let next = 0 + await Promise.all(Array.from({ length: Math.min(jobs, values.length) }, async () => { + while (next < values.length) { + const index = next + next += 1 + const value = values[index] + if (value === undefined) return + results[index] = await operation(value) + } + })) + return results +} + +async function main(): Promise { + const options = parseNextPackageBenchmarkOptions(process.argv.slice(2)) + const root = resolve(import.meta.dirname, '..') + const packages = readWorkspacePackageManifests(root) + const workspaceVersions = new Map(packages.all.map(pkg => [pkg.name, currentVersion(pkg)])) + const releaseByName = new Map(packages.release.map(pkg => [pkg.name, pkg])) + const state = readPackageDependencyState(root) + if (state.policyViolations.length > 0) throw new Error(state.policyViolations.join('\n')) + const base = cloneIndex(buildRegistryIndex(root)) + for (const facts of state.facts) applyFactsToRegistry(base, facts, workspaceVersions) + const targetVersion = workspaceVersions.get(TARGET_PACKAGE) + if (targetVersion === undefined) throw new Error(`workspace has no ${TARGET_PACKAGE}`) + const policyNames = new Set(state.facts.map(facts => facts.manifest.name).filter(name => name !== undefined)) + const discovered = discoverBenchmarkCandidates(base, workspaceVersions, releaseByName, policyNames) + const candidates = options.candidates ?? discovered + for (const name of candidates) { + if (!discovered.includes(name)) throw new Error(`${name} is not a reachable unconfigured Host candidate`) + } + const candidateFacts = new Map(candidates.map((name) => { + const pkg = releaseByName.get(name) + if (pkg === undefined) throw new Error(`release set has no ${name}`) + return [name, readPackageDependencyFacts(root, pkg, 'configured-host', state.workspaceNames)] + })) + + const baselineSeconds = await measure(base, targetVersion, options.finalistRuns, options.timeoutMs) + const baseline = median(baselineSeconds) + console.log(JSON.stringify({ type: 'baseline', seconds: baselineSeconds, medianSeconds: baseline })) + + const coarse = await mapConcurrent(candidates, options.jobs, async (name): Promise => { + const index = cloneIndex(base) + const facts = candidateFacts.get(name) + if (facts === undefined) throw new Error(`missing source facts for ${name}`) + applyFactsToRegistry(index, facts, workspaceVersions) + const seconds = await measure(index, targetVersion, options.coarseRuns, options.timeoutMs) + const result = { package: name, seconds, medianSeconds: median(seconds) } + console.log(JSON.stringify({ type: 'coarse', ...result })) + return result + }) + const finalists = coarse.sort((left, right) => left.medianSeconds - right.medianSeconds) + .slice(0, options.finalists) + const measured: Measurement[] = [] + for (const finalist of finalists) { + const index = cloneIndex(base) + const facts = candidateFacts.get(finalist.package) + if (facts === undefined) throw new Error(`missing source facts for ${finalist.package}`) + applyFactsToRegistry(index, facts, workspaceVersions) + const seconds = await measure(index, targetVersion, options.finalistRuns, options.timeoutMs) + measured.push({ package: finalist.package, seconds, medianSeconds: median(seconds) }) + } + const ranking = measured.sort((left, right) => left.medianSeconds - right.medianSeconds) + .map(result => ({ + ...result, + gainSeconds: Number((baseline - result.medianSeconds).toFixed(2)), + })) + console.log(JSON.stringify({ + type: 'result', + baselineSeconds, + baselineMedianSeconds: baseline, + candidateCount: candidates.length, + ranking, + }, null, 2)) +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error(`benchmark-next-package-dependency: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/scripts/benchmark-npm-resolution.spec.ts b/scripts/benchmark-npm-resolution.spec.ts new file mode 100644 index 0000000000..92a6bdbfcb --- /dev/null +++ b/scripts/benchmark-npm-resolution.spec.ts @@ -0,0 +1,84 @@ +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 { + benchmarkNpmResolution, + buildRegistryIndex, + parseBenchmarkOptions, + publishWorkspaceRange, + type RegistryIndex, +} from './benchmark-npm-resolution.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function writeJson(root: string, path: string, value: unknown): void { + const absolute = join(root, path) + mkdirSync(dirname(absolute), { recursive: true }) + writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`) +} + +describe('npm resolution benchmark', () => { + it('parses repeat, timeout, threshold, and ref options', () => { + expect(parseBenchmarkOptions([])).toEqual({ runs: 1, timeoutMs: 300_000 }) + expect(parseBenchmarkOptions([ + '--runs', '3', '--timeout-ms', '45000', '--max-ms', '20000', '--ref', 'master', + ])).toEqual({ runs: 3, timeoutMs: 45_000, maxMs: 20_000, ref: 'master' }) + expect(parseBenchmarkOptions(['--', '--runs', '2'])).toEqual({ runs: 2, timeoutMs: 300_000 }) + expect(() => parseBenchmarkOptions(['--runs', '0'])).toThrow('--runs must be a positive integer') + }) + + it('projects workspace protocols to published ranges', () => { + expect(publishWorkspaceRange('workspace:^', '1.2.3')).toBe('^1.2.3') + expect(publishWorkspaceRange('workspace:~', '1.2.3')).toBe('~1.2.3') + expect(publishWorkspaceRange('workspace:*', '1.2.3')).toBe('1.2.3') + expect(publishWorkspaceRange('^4.0.0', '1.2.3')).toBe('^4.0.0') + }) + + it('combines installed metadata with current publishable workspace fields', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-npm-registry-index-')) + roots.push(root) + writeJson(root, 'node_modules/.pnpm/external@2.0.0/node_modules/external/package.json', { + name: 'external', + version: '2.0.0', + dependencies: { child: '^1.0.0' }, + devDependencies: { ignored: '^1.0.0' }, + }) + writeJson(root, 'apps/cli/package.json', { + name: '@deepseek-ai/dsh', + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-child': 'workspace:^', external: '^2.0.0' }, + devDependencies: { ignored: 'workspace:^' }, + }) + writeJson(root, 'packages/core/child/package.json', { + name: '@deepseek-ai/dsh-child', + version: '0.1.0', + }) + + const index = buildRegistryIndex(root) + + expect(index.get('external')?.get('2.0.0')).toMatchObject({ dependencies: { child: '^1.0.0' } }) + expect(index.get('@deepseek-ai/dsh')?.get('0.1.0')).toEqual({ + name: '@deepseek-ai/dsh', + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-child': '^0.1.0', external: '^2.0.0' }, + }) + }) + + it('runs npm against the local registry without requesting an archive', async () => { + const index: RegistryIndex = new Map([[ + '@deepseek-ai/dsh', + new Map([['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }]]), + ]]) + const result = await benchmarkNpmResolution(index, '0.1.0', 10_000) + + expect(result.durationMs).toBeGreaterThan(0) + expect(result.registryRequests).toBeGreaterThan(0) + expect(result.archiveRequests).toBe(0) + expect(result.unknownPackages).toEqual([]) + }) +}) diff --git a/scripts/benchmark-npm-resolution.ts b/scripts/benchmark-npm-resolution.ts new file mode 100644 index 0000000000..57834d4f09 --- /dev/null +++ b/scripts/benchmark-npm-resolution.ts @@ -0,0 +1,417 @@ +/** Benchmark npm's dependency-tree resolution against an all-local registry. */ + +import { execFileSync, spawn } from 'node:child_process' +import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { parseArgs } from 'node:util' + +const TARGET_PACKAGE = '@deepseek-ai/dsh' +const DEFAULT_TIMEOUT_MS = 300_000 +const WORKSPACE_MANIFEST_GLOBS = [ + 'apps/*/package.json', + 'packages/*/*/package.json', + 'vendor/*/package.json', + 'native/landlock-run/package.json', + 'native/landlock-run/packages/*/package.json', +] +const INSTALLED_MANIFEST_GLOBS = [ + 'node_modules/.pnpm/*/node_modules/*/package.json', + 'node_modules/.pnpm/*/node_modules/@*/*/package.json', +] +const PUBLISHED_FIELDS = [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'engines', + 'os', + 'cpu', + 'bin', +] as const + +interface PackageManifest { + readonly name?: unknown + readonly version?: unknown + readonly dependencies?: Record + readonly optionalDependencies?: Record + readonly peerDependencies?: Record + readonly peerDependenciesMeta?: Record + readonly engines?: unknown + readonly os?: unknown + readonly cpu?: unknown + readonly bin?: unknown +} + +interface RegistryVersion extends PackageManifest { + readonly name: string + readonly version: string +} + +/** Package versions served by the local benchmark registry. */ +export type RegistryIndex = ReadonlyMap> + +/** Parsed command-line options for one benchmark invocation. */ +export interface BenchmarkOptions { + readonly ref?: string + readonly runs: number + readonly timeoutMs: number + readonly maxMs?: number +} + +/** One measured npm resolution. */ +export interface BenchmarkRun { + readonly durationMs: number + readonly registryRequests: number + readonly archiveRequests: number + readonly unknownPackages: readonly string[] +} + +/** Parse one positive-integer command-line option or use its default. */ +export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number { + if (raw === undefined) return fallback + const value = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(value) || value < 1 || String(value) !== raw) { + throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`) + } + return value +} + +/** + * Parse supported benchmark arguments. + * @param args - Command-line arguments after the script path. + * @returns Validated benchmark options. + */ +export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions { + const normalized = args[0] === '--' ? args.slice(1) : args + const { values } = parseArgs({ + args: [...normalized], + options: { + ref: { type: 'string' }, + runs: { type: 'string' }, + 'timeout-ms': { type: 'string' }, + 'max-ms': { type: 'string' }, + }, + allowPositionals: false, + }) + const maxMs = values['max-ms'] === undefined + ? undefined + : parsePositiveIntegerOption(values['max-ms'], 0, '--max-ms') + return { + runs: parsePositiveIntegerOption(values.runs, 1, '--runs'), + timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], DEFAULT_TIMEOUT_MS, '--timeout-ms'), + ...(values.ref === undefined ? {} : { ref: values.ref }), + ...(maxMs === undefined ? {} : { maxMs }), + } +} + +function workspaceManifestPath(path: string): boolean { + return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/landlock-run(?:\/packages\/[^/]+)?)\/package\.json$/.test(path) +} + +function workspaceManifestPaths(root: string, ref: string | undefined): string[] { + if (ref === undefined) return globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).sort() + return execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'apps', 'packages', 'vendor', 'native'], { + cwd: root, + encoding: 'utf8', + }).split('\n').filter(workspaceManifestPath).sort() +} + +function readGitFiles(root: string, ref: string, paths: readonly string[]): ReadonlyMap { + const output = execFileSync('git', ['cat-file', '--batch'], { + cwd: root, + input: paths.map(path => `${ref}:${path}\n`).join(''), + maxBuffer: 64 * 1024 * 1024, + }) + const contents = new Map() + let offset = 0 + for (const path of paths) { + const headerEnd = output.indexOf(0x0a, offset) + if (headerEnd < 0) throw new Error(`git cat-file returned no header for ${ref}:${path}`) + const header = output.subarray(offset, headerEnd).toString('utf8') + if (header.endsWith(' missing')) throw new Error(`git ref ${ref} has no ${path}`) + const size = Number.parseInt(header.split(' ')[2] ?? '', 10) + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error(`git cat-file returned an invalid size for ${ref}:${path}`) + } + const contentStart = headerEnd + 1 + const contentEnd = contentStart + size + if (output[contentEnd] !== 0x0a) throw new Error(`git cat-file truncated ${ref}:${path}`) + contents.set(path, output.subarray(contentStart, contentEnd).toString('utf8')) + offset = contentEnd + 1 + } + return contents +} + +/** + * Convert a workspace protocol range to the range published by pnpm pack. + * @param range - Dependency range from a workspace manifest. + * @param targetVersion - Current version of the referenced workspace package. + * @returns The registry-facing semver range. + */ +export function publishWorkspaceRange(range: string, targetVersion: string): string { + if (range === 'workspace:*') return targetVersion + if (range === 'workspace:^') return `^${targetVersion}` + if (range === 'workspace:~') return `~${targetVersion}` + if (range.startsWith('workspace:')) return range.slice('workspace:'.length) + return range +} + +function copyPublishedManifest( + source: PackageManifest, + workspaceVersions: ReadonlyMap, +): RegistryVersion | undefined { + if (typeof source.name !== 'string' || typeof source.version !== 'string') return undefined + const output: Record = { name: source.name, version: source.version } + for (const field of PUBLISHED_FIELDS) { + const value = source[field] + if (value === undefined) continue + if (field === 'dependencies' || field === 'optionalDependencies' || field === 'peerDependencies') { + output[field] = Object.fromEntries(Object.entries(value as Record).map(([name, range]) => { + const targetVersion = workspaceVersions.get(name) + return [name, targetVersion === undefined ? range : publishWorkspaceRange(range, targetVersion)] + })) + } else { + output[field] = structuredClone(value) + } + } + return output as unknown as RegistryVersion +} + +function addManifest(index: Map>, manifest: RegistryVersion): void { + const versions = index.get(manifest.name) ?? new Map() + versions.set(manifest.version, manifest) + index.set(manifest.name, versions) +} + +/** + * Build registry metadata from installed external packages and workspace manifests. + * @param root - Repository root containing the pnpm virtual store. + * @param ref - Optional Git ref used instead of working-tree workspace manifests. + * @returns Package metadata served by the benchmark registry. + */ +export function buildRegistryIndex(root: string, ref?: string): RegistryIndex { + const index = new Map>() + for (const path of globSync(INSTALLED_MANIFEST_GLOBS, { cwd: root }).sort()) { + const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest + const copied = copyPublishedManifest(manifest, new Map()) + if (copied !== undefined) addManifest(index, copied) + } + + const paths = workspaceManifestPaths(root, ref) + const refContents = ref === undefined ? undefined : readGitFiles(root, ref, paths) + const workspace = paths.map(path => + JSON.parse(refContents?.get(path) ?? readFileSync(resolve(root, path), 'utf8')) as PackageManifest) + const workspaceVersions = new Map(workspace.flatMap(manifest => + typeof manifest.name === 'string' && typeof manifest.version === 'string' + ? [[manifest.name, manifest.version] as const] + : [])) + for (const manifest of workspace) { + const copied = copyPublishedManifest(manifest, workspaceVersions) + if (copied !== undefined) addManifest(index, copied) + } + return index +} + +function latestVersion(versions: ReadonlyMap): string { + const sorted = [...versions.keys()].sort((left, right) => left.localeCompare(right, 'en', { numeric: true })) + const latest = sorted.at(-1) + if (latest === undefined) throw new Error('local registry package has no versions') + return latest +} + +function listen(server: Server): Promise { + return new Promise((resolveListen, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + const address = server.address() + if (address === null || typeof address === 'string') { + reject(new Error('local registry did not expose a TCP port')) + return + } + resolveListen(address.port) + }) + }) +} + +function close(server: Server): Promise { + return new Promise((resolveClose, reject) => { + server.close((error) => { + if (error === undefined) resolveClose() + else reject(error) + }) + }) +} + +function npmExecutable(): string { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +async function runNpm( + cwd: string, + registry: string, + timeoutMs: number, +): Promise<{ durationMs: number; output: string; timedOut: boolean }> { + const started = performance.now() + const child = spawn(npmExecutable(), [ + 'install', + '--package-lock-only', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--loglevel=error', + `--registry=${registry}`, + ], { + cwd, + // Windows resolves npm through a .cmd shim, which spawn() refuses + // without a shell since the CVE-2024-27980 hardening. + shell: process.platform === 'win32', + env: { + ...process.env, + npm_config_cache: join(cwd, '.npm-cache'), + npm_config_update_notifier: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { output += String(chunk) }) + child.stderr.on('data', (chunk) => { output += String(chunk) }) + const outcome = await new Promise<{ status: number | null; timedOut: boolean }>((resolveExit, reject) => { + let timeoutReached = false + const timer = setTimeout(() => { + timeoutReached = true + child.kill('SIGTERM') + }, timeoutMs) + child.once('error', reject) + child.once('exit', (status) => { + clearTimeout(timer) + resolveExit({ status, timedOut: timeoutReached }) + }) + }) + const durationMs = performance.now() - started + if (outcome.timedOut) return { durationMs, output, timedOut: true } + if (outcome.status !== 0) { + throw new Error(`npm install exited ${String(outcome.status)} after ${durationMs.toFixed(0)} ms\n${output.trim()}`) + } + return { durationMs, output, timedOut: false } +} + +/** + * Resolve the CLI install graph once without downloading package archives. + * @param index - Package metadata exposed through the local registry. + * @param targetVersion - Version of `@deepseek-ai/dsh` to install. + * @param timeoutMs - Hard wall-clock limit for the npm child process. + * @returns Timing and registry-request observations. + */ +export async function benchmarkNpmResolution( + index: RegistryIndex, + targetVersion: string, + timeoutMs: number, +): Promise { + let registryRequests = 0 + let archiveRequests = 0 + const unknownPackages = new Set() + let registry = '' + const server = createServer((request, response) => { + registryRequests++ + const pathname = new URL(request.url ?? '/', registry).pathname + if (pathname.startsWith('/tarballs/')) { + archiveRequests++ + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'package-lock-only benchmark requested an archive' })) + return + } + const name = decodeURIComponent(pathname.slice(1)) + const versions = index.get(name) + if (versions === undefined) { + unknownPackages.add(name) + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'not_found' })) + return + } + const materialized = Object.fromEntries([...versions].map(([version, manifest]) => [version, { + ...manifest, + dist: { tarball: `${registry}tarballs/${encodeURIComponent(name)}-${version}.tgz` }, + }])) + const body = JSON.stringify({ + name, + 'dist-tags': { latest: latestVersion(versions) }, + versions: materialized, + }) + response.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }) + response.end(body) + }) + const port = await listen(server) + registry = `http://127.0.0.1:${String(port)}/` + const consumer = mkdtempSync(join(tmpdir(), 'dsh-npm-resolution-')) + try { + writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({ + name: 'dsh-npm-resolution-benchmark', + version: '0.0.0', + private: true, + dependencies: { [TARGET_PACKAGE]: targetVersion }, + }, null, 2)}\n`) + const result = await runNpm(consumer, registry, timeoutMs) + if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`) + return { + durationMs: result.durationMs, + registryRequests, + archiveRequests, + unknownPackages: [...unknownPackages].sort(), + } + } finally { + await close(server) + rmSync(consumer, { recursive: true, force: true }) + } +} + +async function main(): Promise { + const options = parseBenchmarkOptions(process.argv.slice(2)) + const root = resolve(import.meta.dirname, '..') + const started = performance.now() + const index = buildRegistryIndex(root, options.ref) + const targetVersions = index.get(TARGET_PACKAGE) + if (targetVersions === undefined) throw new Error(`local registry contains no ${TARGET_PACKAGE}`) + const targetVersion = latestVersion(targetVersions) + const npmVersion = execFileSync(npmExecutable(), ['--version'], { encoding: 'utf8' }).trim() + console.log( + `benchmark-npm-resolution: npm ${npmVersion}, ${options.ref === undefined ? 'working tree' : options.ref}, ` + + `${String(index.size)} package name(s), setup ${(performance.now() - started).toFixed(0)} ms.`, + ) + const durations: number[] = [] + for (let run = 1; run <= options.runs; run++) { + const result = await benchmarkNpmResolution(index, targetVersion, options.timeoutMs) + durations.push(result.durationMs) + console.log( + `benchmark-npm-resolution: run ${String(run)}/${String(options.runs)} resolved ${TARGET_PACKAGE}@${targetVersion}` + + ` in ${(result.durationMs / 1000).toFixed(2)} s with ${String(result.registryRequests)} metadata request(s)` + + ` and ${String(result.unknownPackages.length)} local 404 package name(s).`, + ) + if (result.archiveRequests > 0) throw new Error('npm requested package archives during the metadata-only benchmark') + } + const minimum = Math.min(...durations) + const maximum = Math.max(...durations) + console.log( + `benchmark-npm-resolution: ${String(options.runs)} run(s), min ${(minimum / 1000).toFixed(2)} s, max ${(maximum / 1000).toFixed(2)} s.`, + ) + if (options.maxMs !== undefined && maximum > options.maxMs) { + throw new Error(`npm resolution exceeded --max-ms=${String(options.maxMs)} (max ${maximum.toFixed(0)} ms)`) + } +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error(`benchmark-npm-resolution: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts new file mode 100644 index 0000000000..1a172daafc --- /dev/null +++ b/scripts/package-dependency-policy.ts @@ -0,0 +1,52 @@ +/** Explicit exceptions and Host packages for the published dependency policy. */ + +/** Packages treated as Client/Host packages without declaring `dsh.client`. */ +const CLIENT_FACE_INCLUDE: readonly string[] = [] + +/** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ +const CLIENT_FACE_EXCLUDE: readonly string[] = [ + '@deepseek-ai/dsh-api-session-controller', +] + +/** Host-only packages whose peer relays are deliberately flattened. */ +const HOST_DEPENDENCY_PACKAGES: readonly string[] = [ + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session', +] + +/** Complete configurable input to package dependency classification. */ +export interface PackageDependencyPolicy { + readonly clientFaceInclude: readonly string[] + readonly clientFaceExclude: readonly string[] + readonly hostPackages: readonly string[] +} + +/** Repository dependency policy consumed by verification and benchmarking. */ +export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = { + clientFaceInclude: CLIENT_FACE_INCLUDE, + clientFaceExclude: CLIENT_FACE_EXCLUDE, + hostPackages: HOST_DEPENDENCY_PACKAGES, +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Whether a package manifest declares a dynamically loaded Client entry. */ +export function hasClientDeclaration(dshField: unknown): boolean { + return isRecord(dshField) && Object.hasOwn(dshField, 'client') +} + +/** Whether the repository policy flattens one package's non-Cordis peers. */ +export function usesFlattenedPackageDependencies( + manifestPath: string, + packageName: string, + dshField: unknown, + policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, +): boolean { + if (!manifestPath.startsWith('packages/') || manifestPath.startsWith('packages/experimental/')) return false + if (policy.hostPackages.includes(packageName)) return true + if (manifestPath.startsWith('packages/client/')) return true + const included = hasClientDeclaration(dshField) || policy.clientFaceInclude.includes(packageName) + return included && !policy.clientFaceExclude.includes(packageName) +} diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 626aa59bce..d972e36816 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { collectPackageInvariantViolations, } from './package-invariants.ts' +import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts' const roots: string[] = [] @@ -28,7 +29,10 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo function fixture(options: { packageName?: string + packageDirectory?: string source?: string + clientDeclaration?: boolean + clientExport?: boolean invariantExport?: boolean invariantDependency?: boolean invariantReference?: boolean @@ -36,19 +40,34 @@ function fixture(options: { } = {}): string { const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-')) roots.push(root) - const dir = join(root, 'packages/core/probe') + const packageDirectory = options.packageDirectory ?? 'packages/core/probe' + const dir = join(root, packageDirectory) mkdirSync(join(dir, 'src'), { recursive: true }) const packageName = options.packageName ?? '@deepseek-ai/dsh-probe' + const exports = options.invariantExport === false ? {} : { + './invariant': { + types: './lib/types/invariant.d.ts', + default: './lib/invariant.js', + }, + ...(options.clientExport === true ? { + './client': { + types: './lib/types/client/index.d.ts', + default: './lib/client.js', + }, + } : {}), + } + const dsh = options.clientDeclaration === true ? { client: {} } : undefined + const developmentOnlyInvariant = usesFlattenedPackageDependencies( + `${packageDirectory}/package.json`, + packageName, + dsh, + ) const manifest = { name: packageName, - exports: options.invariantExport === false ? {} : { - './invariant': { - types: './lib/types/invariant.d.ts', - default: './lib/invariant.js', - }, - }, + ...(dsh === undefined ? {} : { dsh }), + exports, files: ['lib/index.js', 'lib/invariant.js'], - peerDependencies: options.invariantDependency === false ? {} : { + peerDependencies: options.invariantDependency === false || developmentOnlyInvariant ? {} : { '@deepseek-ai/dsh-invariants': 'workspace:^', }, devDependencies: options.invariantDependency === false ? {} : { @@ -72,6 +91,33 @@ describe('package invariant gate', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) + it('accepts development-only invariants for configured Host dependencies', () => { + expect(collectPackageInvariantViolations(fixture({ packageName: '@deepseek-ai/dsh-llm' }))).toEqual([]) + }) + + it('accepts development-only invariants for client packages', () => { + expect(collectPackageInvariantViolations(fixture({ + packageName: '@deepseek-ai/dsh-client-probe', + packageDirectory: 'packages/client/probe', + }))).toEqual([]) + }) + + it('accepts development-only invariants for packages with a dsh.client entry', () => { + expect(collectPackageInvariantViolations(fixture({ clientDeclaration: true, clientExport: true }))).toEqual([]) + }) + + it('keeps invariant peers for packages that only export a Client API', () => { + expect(collectPackageInvariantViolations(fixture({ clientExport: true }))).toEqual([]) + }) + + it('keeps invariant peers for experimental packages with a dsh.client entry', () => { + expect(collectPackageInvariantViolations(fixture({ + packageDirectory: 'packages/experimental/probe', + clientDeclaration: true, + clientExport: true, + }))).toEqual([]) + }) + it('accepts an invariant reference owned by a package-local leaf project', () => { const root = fixture({ invariantReference: false }) const dir = join(root, 'packages/core/probe') diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 3e4e5ac757..eeafd0e9fb 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -7,12 +7,14 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' +import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts' /** Required explanation marker for an intentionally empty installer. */ const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' interface PackageManifest { name?: string + dsh?: unknown exports?: Record files?: string[] peerDependencies?: Record @@ -96,18 +98,23 @@ function checkManifest( addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js') } if (owner.packageName === '@deepseek-ai/dsh-invariants') return - if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') { - addViolation( - violations, - owner.manifestPath, - '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency', - ) + const developmentOnlyInvariant = usesFlattenedPackageDependencies( + owner.manifestPath, + owner.packageName, + manifest.dsh, + ) + const expectedRange = 'workspace:^' + const peerRange = manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] + if (developmentOnlyInvariant ? peerRange !== undefined : peerRange !== expectedRange) { + addViolation(violations, owner.manifestPath, developmentOnlyInvariant + ? '@deepseek-ai/dsh-invariants must not be a peerDependency under this package dependency policy' + : '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency') } - if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') { + if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== expectedRange) { addViolation( violations, owner.manifestPath, - '@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency', + `@deepseek-ai/dsh-invariants must be a ${expectedRange} devDependency`, ) } } diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 0033a55958..f30dc4adca 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -102,7 +102,7 @@ describe('gate graph validation', () => { const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id)) expect(ids).toEqual([ - 'rescope-vendor', 'publint', 'constraints', 'application-entrypoints', + 'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config', 'runtime-closure', 'vendored-links', @@ -141,6 +141,15 @@ describe('gate graph validation', () => { }, ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( + 'keeps package dependency enforcement in %s', + (mode) => { + const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id)) + + expect(ids).toContain('package-dependencies') + }, + ) + it.each(['ci-primary', 'ci-static', 'check-all'] as const)( 'keeps the client dependency policy in %s', (mode) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f3052d6c25..9a71f1876e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -278,6 +278,7 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }), pnpmScript('constraints', 'constraints'), + pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), @@ -667,6 +668,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), + pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index aa39b84316..05a4acf031 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -1,4 +1,4 @@ -/** Tests for client package modes, dependency sections, and module requests. */ +/** Tests for client package modes and module requests. */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { collectClientPackageViolations, + collectLocalSourceSpecifiers, collectRuntimeSourcePackageUses, collectRuntimeSourceSpecifiers, collectSourcePackageUses, @@ -106,6 +107,19 @@ describe('source package uses', () => { '@deepseek-ai/dsh-b/remote', 'react', ]) + expect([...collectLocalSourceSpecifiers('feature.ts', [ + "import type { A } from './types.ts'", + "export { value } from './value.ts'", + "const load = () => import('./lazy.ts')", + "const legacy = require('./legacy.ts')", + "declare module './augmentation.ts' {}", + "import '@deepseek-ai/dsh-a'", + ].join('\n'))].sort()).toEqual([ + './lazy.ts', + './legacy.ts', + './types.ts', + './value.ts', + ]) }) }) @@ -152,109 +166,6 @@ 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 conversation = pkg('conversation', { - inject: ['@deepseek-ai/dsh-client-feature'], - sourceUses: { - '@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: { - [CORDIS]: 'workspace:^', - '@deepseek-ai/dsh-agent': 'workspace:^', - '@deepseek-ai/dsh-client-feature': 'workspace:^', - }, - devDependencies: { - [CORDIS]: 'workspace:^', - '@deepseek-ai/dsh-agent': 'workspace:^', - '@deepseek-ai/dsh-client-feature': 'workspace:^', - '@deepseek-ai/dsh-client-ui-slots': 'workspace:^', - react: '^18.2.0', - }, - }) - expect(collectClientPackageViolations(facts([slots, conversation], { - platformModules: ['react', slots.name], - }))).toEqual([]) - }) - - it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => { - const slots = pkg('ui-slots', { dynamic: false, staticLinked: true }) - const subject = pkg('feature', { - sourceUses: { - '@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'], - [slots.name]: ['packages/client/feature/src/view.tsx'], - }, - dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' }, - peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' }, - devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' }, - }) - const found = collectClientPackageViolations(facts([slots, subject])) - expect(found).toHaveLength(2) - expect(found.join('\n')).toContain('peer-installed DSH relationship') - expect(found.join('\n')).toContain('static client input') - }) - - it('requires every peer to have the same development range', () => { - const subject = pkg('feature', { - peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' }, - }) - expect(collectClientPackageViolations(facts([subject]))).toEqual([ - 'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader' - + ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;' - + ' found no declaration', - ]) - }) - - it('requires statically linked third-party runtime imports in dependencies', () => { - const primitives = pkg('ui-primitives', { - dynamic: false, - staticLinked: true, - runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] }, - devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' }, - }) - const found = collectClientPackageViolations(facts([primitives])) - expect(found).toHaveLength(1) - expect(found[0]).toContain('runtime import retained by a statically linked artifact') - expect(found[0]).toContain('declare it only in dependencies') - - const valid = { ...primitives, dependencies: { shiki: '^4.3.1' }, devDependencies: { [CORDIS]: 'workspace:^' } } - expect(collectClientPackageViolations(facts([valid]))).toEqual([]) - }) - - it('keeps the web shell runtime inputs development-only', () => { - const web = pkg('web', { - dynamic: false, - staticLinked: true, - runtimeSourceUses: { - '@deepseek-ai/cordis-plugin-loader': ['packages/client/web/src/boot.ts'], - react: ['packages/client/web/src/seed.ts'], - }, - devDependencies: { - [CORDIS]: 'workspace:^', - '@deepseek-ai/cordis-plugin-loader': 'workspace:^', - react: '^18.2.0', - }, - }) - expect(collectClientPackageViolations(facts([web]))).toEqual([]) - }) - - it('allows npm dependency cycles', () => { - const a = pkg('a', { - peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' }, - devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' }, - }) - const b = pkg('b', { - peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' }, - devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' }, - }) - expect(collectClientPackageViolations(facts([a, b]))).toEqual([]) - }) -}) - describe('module requests', () => { it('rejects runtime requests from one client feature package to another dynamic row', () => { const ui = declaration('ui', { @@ -378,7 +289,7 @@ describe('manifest declarations', () => { ]) }) - it('fixes unambiguous dependency sections and declaration entries', () => { + it('fixes malformed declaration entries without changing dependency sections', () => { const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-')) roots.push(root) const subject = pkg('feature', { @@ -426,43 +337,8 @@ describe('manifest declarations', () => { external: ['@deepseek-ai/dsh-missing'], inject: ['@deepseek-ai/dsh-agent'], }) - expect(fixed.dependencies).toBeUndefined() - expect(fixed.peerDependencies).toEqual({ - '@deepseek-ai/cordis-plugin-loader': 'workspace:^', - [CORDIS]: 'workspace:^', - '@deepseek-ai/dsh-agent': 'workspace:*', - }) - expect(fixed.devDependencies).toEqual({ - '@deepseek-ai/dsh-client-ui-slots': 'workspace:^', - [CORDIS]: 'workspace:^', - '@deepseek-ai/dsh-agent': 'workspace:*', - '@deepseek-ai/cordis-plugin-loader': 'workspace:^', - }) - }) - - it('fixes a statically linked runtime import into dependencies', () => { - const root = mkdtempSync(join(tmpdir(), 'client-packages-static-fix-')) - roots.push(root) - const subject = pkg('ui-primitives', { - dynamic: false, - staticLinked: true, - runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] }, - devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' }, - }) - mkdirSync(dirname(join(root, subject.manifest)), { recursive: true }) - writeFileSync(join(root, subject.manifest), JSON.stringify({ - name: subject.name, - peerDependencies: subject.peerDependencies, - devDependencies: subject.devDependencies, - })) - writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true })) - - expect(fixClientPackageManifests(root, facts([subject]))).toEqual([subject.manifest]) - const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as { - dependencies: Record - devDependencies: Record - } - expect(fixed.dependencies).toEqual({ shiki: '^4.3.1' }) - expect(fixed.devDependencies).toEqual({ [CORDIS]: 'workspace:^' }) + expect(fixed.dependencies).toEqual(subject.dependencies) + expect(fixed.peerDependencies).toEqual(subject.peerDependencies) + expect(fixed.devDependencies).toEqual(subject.devDependencies) }) }) diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index 294cba6328..94e70497ae 100644 --- a/scripts/verify-client-packages.ts +++ b/scripts/verify-client-packages.ts @@ -1,6 +1,5 @@ /** - * Verify client package modes, npm dependency sections, and the synchronous - * browser module-request graph. + * Verify client package modes and the synchronous browser module-request graph. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -17,8 +16,6 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts' const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts' const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts' const CORDIS = '@deepseek-ai/cordis' -const DSH_PREFIX = '@deepseek-ai/dsh-' -const CLIENT_WEB = '@deepseek-ai/dsh-client-web' /** One workspace package's browser-module declaration. */ export interface ClientDeclaration { @@ -92,6 +89,17 @@ export function collectRuntimeSourceSpecifiers(path: string, source: string): Se return collectSourceFileUses(sourceFile, true, 'specifier') } +/** + * Collect relative module specifiers used to follow one source entry's local closure. + * @param path - File path used to select TypeScript's parser mode. + * @param source - Source text to inspect. + * @returns Relative imports, exports, requires, and import types. + */ +export function collectLocalSourceSpecifiers(path: string, source: string): Set { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + return collectSourceFileUses(sourceFile, false, 'local') +} + function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean { const clause = node.importClause if (clause === undefined) return true @@ -114,12 +122,17 @@ function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean { function collectSourceFileUses( sourceFile: ts.SourceFile, runtimeOnly: boolean, - key: 'package' | 'specifier', + key: 'local' | 'package' | 'specifier', ): Set { const uses = new Set() const add = (specifier: ts.Expression | undefined): void => { - if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return + if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return + if (key === 'local') { + if (specifier.text.startsWith('.')) uses.add(specifier.text) + return + } + if (!isBareSpecifier(specifier.text)) return uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text) } const visit = (node: ts.Node): void => { @@ -135,9 +148,10 @@ function collectSourceFileUses( && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === 'require')) { add(node.arguments[0]) - } else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + } else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { add(node.name) - } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + } else if (key !== 'local' + && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) { uses.add('react') } ts.forEachChild(node, visit) @@ -170,7 +184,6 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin return [ ...facts.malformed, ...collectModeViolations(facts), - ...collectDependencyViolations(facts), ...collectModuleViolations(facts), ].sort((left, right) => left.localeCompare(right)) } @@ -181,10 +194,8 @@ interface ManifestDocument { changed: boolean } -type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies' - /** - * Repair manifest declarations whose intended result follows uniquely from the policy. + * Repair malformed or redundant `dsh.client` declaration entries. * @param root - Absolute repository root. * @param facts - Facts used by the verification pass. * @returns Repository-relative manifests written by the fixer. @@ -217,53 +228,6 @@ export function fixClientPackageManifests(root: string, facts: ClientPackageFact ) || target.changed } - const staticInputs = new Set([ - ...facts.staticLinkedPackages, - ...facts.platformModules.map(packageNameOf), - ]) - staticInputs.delete(CORDIS) - const inferredRanges = dependencyRangeCandidates(root) - for (const pkg of facts.packages) { - const target = document(pkg.manifest) - const expected = expectedSections(pkg, staticInputs) - for (const [name, rule] of expected) { - const range = preferredRange(target.manifest, name, rule.kind, inferredRanges) - if (range === undefined) continue - target.changed = rule.kind === 'dependency' - ? ensureDependencyOnly(target.manifest, name, range) || target.changed - : rule.kind === 'dev' - ? ensureDevOnly(target.manifest, name, range) || target.changed - : ensurePeerDev(target.manifest, name, range) || target.changed - } - - if (pkg.dynamic) { - const productionNames = new Set([ - ...Object.keys(section(target.manifest, 'dependencies')), - ...Object.keys(section(target.manifest, 'peerDependencies')), - ]) - for (const name of productionNames) { - if (expected.has(name)) continue - const range = preferredRange( - target.manifest, - name, - staticInputs.has(name) ? 'dev' : 'peer-dev', - inferredRanges, - ) - if (range === undefined) continue - if (staticInputs.has(name)) { - target.changed = ensureDevOnly(target.manifest, name, range) || target.changed - } else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) { - target.changed = ensurePeerDev(target.manifest, name, range) || target.changed - } - } - } - - for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) { - target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed - } - target.changed = deleteEmptySections(target.manifest) || target.changed - } - const changed = [...documents.values()].filter(target => target.changed).sort((left, right) => left.path.localeCompare(right.path)) for (const target of changed) { @@ -295,102 +259,6 @@ function normalizeClientArray( return true } -function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean { - let changed = deleteDependency(manifest, 'dependencies', name) - changed = deleteDependency(manifest, 'peerDependencies', name) || changed - return setDependency(manifest, 'devDependencies', name, range) || changed -} - -function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean { - let changed = deleteDependency(manifest, 'peerDependencies', name) - changed = deleteDependency(manifest, 'devDependencies', name) || changed - return setDependency(manifest, 'dependencies', name, range) || changed -} - -function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean { - let changed = deleteDependency(manifest, 'dependencies', name) - changed = setDependency(manifest, 'peerDependencies', name, range) || changed - return setDependency(manifest, 'devDependencies', name, range) || changed -} - -function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean { - const dependencies = mutableSection(manifest, field) - if (dependencies[name] === range) return false - dependencies[name] = range - return true -} - -function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean { - const dependencies = section(manifest, field) - if (dependencies[name] === undefined) return false - manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name)) - return true -} - -function deleteEmptySections(manifest: Manifest): boolean { - let changed = false - for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) { - if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue - if (field === 'dependencies') delete manifest.dependencies - else if (field === 'peerDependencies') delete manifest.peerDependencies - else delete manifest.devDependencies - changed = true - } - return changed -} - -function preferredRange( - manifest: Manifest, - name: string, - kind: ExpectedRule['kind'], - inferred: ReadonlyMap>, -): string | undefined { - const order: readonly DependencySection[] = kind === 'dependency' - ? ['dependencies', 'devDependencies', 'peerDependencies'] - : kind === 'dev' - ? ['devDependencies', 'peerDependencies', 'dependencies'] - : ['peerDependencies', 'devDependencies', 'dependencies'] - for (const field of order) { - const range = section(manifest, field)[name] - if (range !== undefined) return range - } - if (isInternalDsh(name)) return 'workspace:^' - const candidates = inferred.get(name) - return candidates?.size === 1 ? [...candidates][0] : undefined -} - -function dependencyRangeCandidates(root: string): Map> { - const candidates = new Map>() - const paths = globSync([ - 'package.json', - ...MANIFEST_GLOBS, - 'website/package.json', - ], { cwd: root }).map(normalizePath) - for (const path of new Set(paths)) { - const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest - for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) { - for (const [name, range] of Object.entries(section(manifest, field))) { - const ranges = candidates.get(name) ?? new Set() - ranges.add(range) - candidates.set(name, ranges) - } - } - } - return candidates -} - -function section(manifest: Manifest, field: DependencySection): Record { - return manifest[field] ?? {} -} - -function mutableSection(manifest: Manifest, field: DependencySection): Record { - const value = manifest[field] - if (value !== undefined) return value - const created: Record = {} - manifest[field] = created - return created -} - function collectModeViolations(facts: ClientPackageFacts): string[] { const violations: string[] = [] for (const pkg of facts.packages) { @@ -435,114 +303,6 @@ function collectModeViolations(facts: ClientPackageFacts): string[] { return violations } -interface ExpectedRule { - readonly kind: 'dependency' | 'dev' | 'peer-dev' - readonly origins: Set -} - -function collectDependencyViolations(facts: ClientPackageFacts): string[] { - const violations: string[] = [] - const staticInputs = new Set([ - ...facts.staticLinkedPackages, - ...facts.platformModules.map(packageNameOf), - ]) - staticInputs.delete(CORDIS) - - for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) { - const expected = expectedSections(pkg, staticInputs) - for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) { - const actual = declaredSections(pkg, name) - if (rule.kind === 'dependency') { - if (actual.length === 1 && actual[0] === 'dependencies') continue - violations.push( - pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import' - + ' retained by a statically linked artifact; declare it only in dependencies, found ' - + describeSections(actual), - ) - continue - } - if (rule.kind === 'dev') { - if (actual.length === 1 && actual[0] === 'devDependencies') continue - violations.push( - pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;' - + ' declare it only in devDependencies, found ' + describeSections(actual), - ) - continue - } - - const peerRange = pkg.peerDependencies[name] - const devRange = pkg.devDependencies[name] - if (actual.length === 2 - && actual.includes('peerDependencies') - && actual.includes('devDependencies') - && peerRange === devRange) continue - violations.push( - pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')' - + ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies' - + ' with matching ranges, not dependencies; found ' + describeSections(actual) - + describeRangeMismatch(peerRange, devRange), - ) - } - - for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) { - if (expected.has(name)) continue - const devRange = pkg.devDependencies[name] - if (devRange === peerRange) continue - violations.push( - pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name - + ' must use the same range; found ' + (devRange ?? 'no declaration'), - ) - } - - if (!pkg.dynamic) continue - for (const section of ['dependencies', 'peerDependencies'] as const) { - for (const name of Object.keys(pkg[section]).sort()) { - if (expected.has(name)) continue - if (staticInputs.has(name)) { - violations.push( - pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';' - + ' move it to devDependencies or delete the stale declaration', - ) - } else if (section === 'dependencies' && isInternalDsh(name)) { - violations.push( - pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;' - + ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only', - ) - } - } - } - } - return violations -} - -function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet): Map { - const expected = new Map([ - [CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }], - ]) - if (!pkg.dynamic) { - if (pkg.name === CLIENT_WEB) return expected - for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) { - if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue - expected.set(name, { kind: 'dependency', origins: new Set(locations) }) - } - return expected - } - - const add = (name: string, origin: string): void => { - if (name === pkg.name) return - const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined - if (kind === undefined) return - const current = expected.get(name) - if (current !== undefined) current.origins.add(origin) - else expected.set(name, { kind, origins: new Set([origin]) }) - } - for (const [name, locations] of Object.entries(pkg.sourceUses)) { - for (const location of locations) add(name, location) - } - for (const name of pkg.inject) add(name, 'dsh.client.inject') - return expected -} - interface ModuleEdge { readonly from: string readonly to: string @@ -895,32 +655,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet): string | un return rows.has(stripped) ? stripped : undefined } -function declaredSections(pkg: ClientPackage, name: string): string[] { - return (['dependencies', 'peerDependencies', 'devDependencies'] as const) - .filter(section => pkg[section][name] !== undefined) -} - -function describeSections(sections: readonly string[]): string { - return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ') -} - -function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string { - if (peer === undefined || dev === undefined || peer === dev) return '' - return ' (peer ' + peer + ', dev ' + dev + ')' -} - -function describeOrigins(origins: ReadonlySet): string { - const sorted = [...origins].sort() - const [first, second, ...rest] = sorted - if (first === undefined) return 'production use' - if (second === undefined) return first - return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more' -} - -function isInternalDsh(name: string): boolean { - return name === CORDIS || name.startsWith(DSH_PREFIX) -} - function isBareSpecifier(specifier: string): boolean { return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#') } @@ -956,7 +690,7 @@ async function main(): Promise { const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0) console.log( GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, ' - + String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; ' + + String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; ' + String(requests) + ' explicit external request(s).', ) } diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts new file mode 100644 index 0000000000..253a5ea7c3 --- /dev/null +++ b/scripts/verify-package-dependencies.spec.ts @@ -0,0 +1,260 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + PACKAGE_DEPENDENCY_POLICY, + type PackageDependencyPolicy, +} from './package-dependency-policy.ts' +import { + collectPackageDependencyViolations, + discoverPackageDependencyScope, + fixPackageDependencies, + formatManagedRuntimeDependencies, + readPackageDependencyFacts, + repairPackageDependencyManifest, + type PackageDependencyFacts, + type PackageDependencyManifest, + type WorkspacePackageManifest, +} from './verify-package-dependencies.ts' + +const CORDIS = '@deepseek-ai/cordis' +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function pkg( + name: string, + manifestPath: string, + manifest: Partial = {}, +): WorkspacePackageManifest { + return { + name, + manifestPath, + dir: dirname(manifestPath), + manifest: { name, ...manifest }, + } +} + +function policy(fields: Partial = {}): PackageDependencyPolicy { + return { + clientFaceInclude: [], + clientFaceExclude: [], + hostPackages: [], + ...fields, + } +} + +function facts(manifest: PackageDependencyManifest): PackageDependencyFacts { + return { + manifestPath: 'packages/core/probe/package.json', + role: 'configured-host', + manifest, + workspaceNames: new Set([ + CORDIS, + '@deepseek-ai/dsh-runtime', + '@deepseek-ai/dsh-types', + '@deepseek-ai/dsh-stale', + '@deepseek-ai/schemastery', + ]), + allSourceUses: new Map([ + ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']], + ['@deepseek-ai/dsh-types', ['packages/core/probe/src/types.ts']], + ]), + hostRuntimeSourceUses: new Map([ + ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']], + ]), + clientInject: new Set(), + } +} + +describe('package dependency scope', () => { + it('keeps the measured Host relay roster explicit', () => { + expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ + '@deepseek-ai/dsh-api-session-controller', + ]) + expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([ + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session', + ]) + }) + + it('discovers the Client directory, dsh.client declarations, and configured Host packages', () => { + const packages = [ + pkg('@f/static', 'packages/client/static/package.json'), + pkg('@f/dynamic-client', 'packages/client/dynamic/package.json', { dsh: { client: {} } }), + pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }), + pkg('@f/export-only', 'packages/api/export-only/package.json', { exports: { './client': './lib/client.js' } }), + pkg('@f/forced-client', 'packages/api/forced/package.json'), + pkg('@f/excluded', 'packages/api/excluded/package.json', { dsh: { client: {} } }), + pkg('@f/host', 'packages/core/host/package.json'), + ] + + const found = discoverPackageDependencyScope(packages, policy({ + clientFaceInclude: ['@f/forced-client'], + clientFaceExclude: ['@f/excluded'], + hostPackages: ['@f/host'], + })) + + expect(found.violations).toEqual([]) + expect(found.selected.map(item => [item.name, item.role])).toEqual([ + ['@f/dual', 'client-host'], + ['@f/forced-client', 'client-host'], + ['@f/dynamic-client', 'client-host'], + ['@f/static', 'client-host'], + ['@f/host', 'configured-host'], + ]) + }) + + it('rejects stale, redundant, overlapping, and unknown configuration', () => { + const packages = [ + pkg('@f/client', 'packages/client/client/package.json'), + pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }), + pkg('@f/host', 'packages/core/host/package.json'), + ] + const found = discoverPackageDependencyScope(packages, policy({ + clientFaceInclude: ['@f/dual', '@f/missing', '@f/host'], + clientFaceExclude: ['@f/client', '@f/host', '@f/missing'], + hostPackages: ['@f/dual'], + })) + + expect(found.violations).toEqual(expect.arrayContaining([ + expect.stringContaining('clientFaceInclude redundantly names automatically discovered package @f/dual'), + expect.stringContaining('@f/host appears in both clientFaceInclude and clientFaceExclude'), + expect.stringContaining('clientFaceExclude cannot exempt packages/client package @f/client'), + expect.stringContaining('clientFaceExclude names @f/host, which declares no dsh.client entry'), + expect.stringContaining('hostPackages redundantly names Client-faced package @f/dual'), + expect.stringContaining('unknown release package @f/missing'), + ])) + }) +}) + +describe('face-aware source classification', () => { + it('counts Host values as dependencies and Client values as development inputs', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-')) + roots.push(root) + const subject = pkg('@f/dual', 'packages/g/dual/package.json', { + dsh: { client: { inject: ['@f/injected'] } }, + }) + const files = { + 'packages/g/dual/src/index.ts': [ + "import { value } from '@f/runtime'", + "import type { Shared } from '@f/types'", + "export { nested } from './nested.ts'", + ].join('\n'), + 'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'", + 'packages/g/dual/src/client/index.ts': "import { browser } from '@f/browser'", + } + for (const [path, source] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), source) + } + + const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([ + CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/browser', '@f/injected', + ])) + + expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime']) + expect([...found.allSourceUses.keys()].sort()).toEqual(['@f/browser', '@f/nested', '@f/runtime', '@f/types']) + }) +}) + +describe('dependency sections', () => { + it('accepts Host dependencies, development-only inputs, and shared Cordis', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { + '@deepseek-ai/dsh-runtime': 'workspace:^', + '@deepseek-ai/schemastery': 'workspace:^', + external: '^1.0.0', + }, + devDependencies: { + '@deepseek-ai/dsh-types': 'workspace:^', + [CORDIS]: 'workspace:^', + }, + peerDependencies: { [CORDIS]: 'workspace:^' }, + } + expect(collectPackageDependencyViolations({ + facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames, + })).toEqual([]) + }) + + it('lists managed Host runtime dependencies for fix review', () => { + const subject = facts({ name: '@deepseek-ai/dsh-probe' }) + expect(formatManagedRuntimeDependencies({ + facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames, + })).toEqual([ + 'verify-package-dependencies: 1 managed Host runtime dependency edge(s) remain in dependencies across 1 package(s):', + ' @deepseek-ai/dsh-probe: @deepseek-ai/dsh-runtime', + ]) + }) + + it('reports wrong sections, workspace ranges, and stale peer metadata', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/dsh-types': 'workspace:*' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + peerDependencies: { [CORDIS]: 'workspace:*', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + peerDependenciesMeta: { '@deepseek-ai/dsh-missing': { optional: true } }, + } + const state = { + facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames, + } + const violations = collectPackageDependencyViolations(state) + expect(violations).toEqual(expect.arrayContaining([ + expect.stringContaining('@deepseek-ai/dsh-runtime'), + expect.stringContaining('@deepseek-ai/dsh-types'), + expect.stringContaining(`${CORDIS} must be matching peerDependencies + devDependencies`), + expect.stringContaining('dependencies.@deepseek-ai/dsh-types must use workspace:^'), + expect.stringContaining('peerDependenciesMeta.@deepseek-ai/dsh-missing has no matching'), + ])) + }) + + it('repairs owned relationships without changing unrelated dependencies', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-dependencies-')) + roots.push(root) + const manifestPath = 'package.json' + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/schemastery': 'workspace:*', external: '^1.0.0' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + peerDependencies: { + [CORDIS]: 'workspace:^', + '@deepseek-ai/dsh-runtime': 'workspace:^', + '@deepseek-ai/dsh-stale': 'workspace:^', + }, + peerDependenciesMeta: { '@deepseek-ai/dsh-stale': { optional: true } }, + } + writeFileSync(join(root, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`) + const subject = { ...facts(manifest), manifestPath } + const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames } + + expect(fixPackageDependencies(root, state)).toEqual([manifestPath]) + const fixed = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as PackageDependencyManifest + expect(fixed.dependencies).toEqual({ + '@deepseek-ai/schemastery': 'workspace:^', + external: '^1.0.0', + '@deepseek-ai/dsh-runtime': 'workspace:^', + }) + expect(fixed.devDependencies).toEqual({ + [CORDIS]: 'workspace:^', + '@deepseek-ai/dsh-types': 'workspace:^', + '@deepseek-ai/dsh-stale': 'workspace:^', + }) + expect(fixed.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' }) + expect(fixed.peerDependenciesMeta).toBeUndefined() + }) + + it('repairs an in-memory manifest for benchmark simulation', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + } + repairPackageDependencyManifest(facts(manifest)) + expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' }) + expect(manifest.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' }) + }) +}) diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts new file mode 100644 index 0000000000..fda6184864 --- /dev/null +++ b/scripts/verify-package-dependencies.ts @@ -0,0 +1,479 @@ +/** Verify and repair npm dependency sections from published Client and Host faces. */ + +import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path' +import { + hasClientDeclaration, + PACKAGE_DEPENDENCY_POLICY, + type PackageDependencyPolicy, +} from './package-dependency-policy.ts' +import { + collectLocalSourceSpecifiers, + collectRuntimeSourcePackageUses, + collectSourcePackageUses, +} from './verify-client-packages.ts' + +const GATE = 'verify-package-dependencies' +const CORDIS = '@deepseek-ai/cordis' +const WORKSPACE_RANGE = 'workspace:^' +const RELEASE_MANIFEST_GLOB = 'packages/!(experimental)/*/package.json' +const WORKSPACE_MANIFEST_GLOBS = [ + 'apps/*/package.json', + 'packages/*/*/package.json', + 'vendor/*/package.json', +] + +type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' +export type PackageDependencyRole = 'client-host' | 'configured-host' + +/** Manifest fields read and repaired by the package dependency policy. */ +export interface PackageDependencyManifest { + name?: string + version?: string + exports?: unknown + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record + dsh?: { client?: { inject?: string[] } } +} + +/** One workspace package and its source location. */ +export interface WorkspacePackageManifest { + readonly dir: string + readonly manifestPath: string + readonly manifest: PackageDependencyManifest + readonly name: string +} + +/** Source and manifest facts for one package covered by the policy. */ +export interface PackageDependencyFacts { + readonly manifestPath: string + readonly role: PackageDependencyRole + readonly manifest: PackageDependencyManifest + readonly workspaceNames: ReadonlySet + readonly allSourceUses: ReadonlyMap + readonly hostRuntimeSourceUses: ReadonlyMap + readonly clientInject: ReadonlySet +} + +/** Complete policy input read from the repository. */ +export interface PackageDependencyState { + readonly facts: readonly PackageDependencyFacts[] + readonly packages: readonly WorkspacePackageManifest[] + readonly policyViolations: readonly string[] + readonly workspaceNames: ReadonlySet +} + +export interface ExpectedPackageDependency { + readonly section: 'dependencies' | 'devDependencies' | 'peer-dev' + readonly origins: readonly string[] +} + +function normalizePath(path: string): string { + return path.split(sep).join('/') +} + +function packageNameOf(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || specifier.includes(':')) { + return undefined + } + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0] +} + +/** Read package manifests used for scope discovery and workspace-name checks. */ +export function readWorkspacePackageManifests(root: string): { + all: WorkspacePackageManifest[] + release: WorkspacePackageManifest[] +} { + const read = (manifestPath: string): WorkspacePackageManifest => { + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as PackageDependencyManifest + if (typeof manifest.name !== 'string') throw new Error(`${manifestPath}: missing package name`) + return { + dir: dirname(manifestPath), + manifestPath, + manifest, + name: manifest.name, + } + } + const all = globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).map(normalizePath).sort().map(read) + const releasePaths = new Set(globSync(RELEASE_MANIFEST_GLOB, { cwd: root }).map(normalizePath)) + return { all, release: all.filter(pkg => releasePaths.has(pkg.manifestPath)) } +} + +function duplicates(values: readonly string[]): string[] { + const seen = new Set() + const duplicated = new Set() + for (const value of values) { + if (seen.has(value)) duplicated.add(value) + seen.add(value) + } + return [...duplicated].sort() +} + +/** Discover Client faces and configured Host packages, validating explicit overrides. */ +export function discoverPackageDependencyScope( + packages: readonly WorkspacePackageManifest[], + policy: PackageDependencyPolicy, +): { selected: Array; violations: string[] } { + const violations: string[] = [] + const byName = new Map(packages.map(pkg => [pkg.name, pkg])) + const include = new Set(policy.clientFaceInclude) + const exclude = new Set(policy.clientFaceExclude) + const host = new Set(policy.hostPackages) + + for (const [field, values] of [ + ['clientFaceInclude', policy.clientFaceInclude], + ['clientFaceExclude', policy.clientFaceExclude], + ['hostPackages', policy.hostPackages], + ] as const) { + for (const name of duplicates(values)) violations.push(`${field} lists ${name} more than once`) + for (const name of values) { + if (!byName.has(name)) violations.push(`${field} names unknown release package ${name}`) + } + } + for (const name of include) { + if (exclude.has(name)) violations.push(`${name} appears in both clientFaceInclude and clientFaceExclude`) + const pkg = byName.get(name) + if (pkg !== undefined + && (pkg.manifestPath.startsWith('packages/client/') || hasClientDeclaration(pkg.manifest.dsh))) { + violations.push(`clientFaceInclude redundantly names automatically discovered package ${name}`) + } + } + for (const name of exclude) { + const pkg = byName.get(name) + if (pkg !== undefined && pkg.manifestPath.startsWith('packages/client/')) { + violations.push(`clientFaceExclude cannot exempt packages/client package ${name}`) + } else if (pkg !== undefined && !hasClientDeclaration(pkg.manifest.dsh)) { + violations.push(`clientFaceExclude names ${name}, which declares no dsh.client entry`) + } + } + + const selected: Array = [] + for (const pkg of packages) { + const clientDirectory = pkg.manifestPath.startsWith('packages/client/') + const clientHost = clientDirectory + || ((hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name)) + const configuredHost = host.has(pkg.name) + if (configuredHost && clientHost) { + violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`) + } + const role = clientHost ? 'client-host' : configuredHost ? 'configured-host' : undefined + if (role !== undefined) selected.push({ ...pkg, role }) + } + return { + selected: selected.sort((left, right) => left.manifestPath.localeCompare(right.manifestPath)), + violations: [...new Set(violations)].sort(), + } +} + +function addUse(target: Map, name: string, path: string): void { + const paths = target.get(name) ?? [] + if (!paths.includes(path)) paths.push(path) + target.set(name, paths) +} + +function resolveLocal(importer: string, specifier: string): string | undefined { + const raw = resolve(dirname(importer), specifier) + const candidates = extname(raw) === '' + ? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')] + : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')] + return candidates.find(candidate => existsSync(candidate)) +} + +function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map { + const uses = new Map() + const seen = new Set() + const visit = (path: string): void => { + const normalized = normalize(path) + if (seen.has(normalized) || !existsSync(normalized)) return + seen.add(normalized) + const source = readFileSync(normalized, 'utf8') + const displayPath = normalizePath(relative(root, normalized)) + for (const name of collectRuntimeSourcePackageUses(normalized, source)) addUse(uses, name, displayPath) + for (const specifier of collectLocalSourceSpecifiers(normalized, source)) { + const target = resolveLocal(normalized, specifier) + if (target !== undefined) visit(target) + } + } + visit(resolve(root, pkg.dir, 'src/index.ts')) + return uses +} + +function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map { + const uses = new Map() + for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) { + const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8') + const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}` + for (const name of collectSourcePackageUses(sourcePath, source)) addUse(uses, name, displayPath) + } + return uses +} + +/** Read source usage for one already-classified package. */ +export function readPackageDependencyFacts( + root: string, + pkg: WorkspacePackageManifest, + role: PackageDependencyRole, + workspaceNames: ReadonlySet, +): PackageDependencyFacts { + const inject = pkg.manifest.dsh?.client?.inject ?? [] + return { + manifestPath: pkg.manifestPath, + role, + manifest: pkg.manifest, + workspaceNames, + allSourceUses: readAllSourceUses(root, pkg), + hostRuntimeSourceUses: readHostRuntimeUses(root, pkg), + clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)), + } +} + +/** Read every package covered by the current dependency policy. */ +export function readPackageDependencyState( + root: string, + policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, +): PackageDependencyState { + const packages = readWorkspacePackageManifests(root) + const workspaceNames = new Set(packages.all.map(pkg => pkg.name)) + const discovered = discoverPackageDependencyScope(packages.release, policy) + return { + facts: discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames)), + packages: packages.release, + policyViolations: discovered.violations, + workspaceNames, + } +} + +/** Derive the required npm section for each relationship owned by the policy. */ +export function expectedPackageDependencies( + facts: PackageDependencyFacts, +): ReadonlyMap { + const expected = new Map }>() + const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => { + if (name === facts.manifest.name || name === CORDIS) return + const current = expected.get(name) + const section = current?.section === 'dependencies' || sectionName === 'dependencies' + ? 'dependencies' + : 'devDependencies' + expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) }) + } + + expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) }) + for (const [name, paths] of facts.allSourceUses) { + if (!facts.workspaceNames.has(name)) continue + for (const path of paths) add(name, 'devDependencies', path) + } + for (const name of facts.clientInject) { + if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject') + } + for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) { + if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer') + } + for (const [name, paths] of facts.hostRuntimeSourceUses) { + if (!facts.workspaceNames.has(name) && facts.manifest.peerDependencies?.[name] === undefined) continue + for (const path of paths) add(name, 'dependencies', path) + } + return new Map([...expected].map(([name, rule]) => [name, { + section: rule.section, + origins: [...rule.origins].sort(), + }])) +} + +/** Format the managed Host runtime edges that remain ordinary dependencies. */ +export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] { + const rows = state.facts.flatMap((facts) => { + const dependencies = [...expectedPackageDependencies(facts)] + .filter(([, rule]) => rule.section === 'dependencies') + .map(([name]) => name) + .sort() + if (dependencies.length === 0) return [] + return [{ + name: facts.manifest.name ?? facts.manifestPath, + dependencies, + }] + }).sort((left, right) => left.name.localeCompare(right.name)) + const edges = rows.reduce((total, row) => total + row.dependencies.length, 0) + return [ + `${GATE}: ${String(edges)} managed Host runtime dependency edge(s) remain in dependencies across ${String(rows.length)} package(s):`, + ...rows.map(row => ` ${row.name}: ${row.dependencies.join(', ')}`), + ] +} + +function section(manifest: PackageDependencyManifest, name: DependencySection): Record { + return manifest[name] ?? {} +} + +function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record { + manifest[name] ??= {} + return manifest[name] +} + +function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] { + return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) + .filter(sectionName => section(manifest, sectionName)[name] !== undefined) +} + +function describeSections(sections: readonly DependencySection[]): string { + return sections.length === 0 ? 'no dependency section' : sections.join(' + ') +} + +/** Return all manifest and policy violations in stable order. */ +export function collectPackageDependencyViolations(state: PackageDependencyState): string[] { + const violations = [...state.policyViolations] + for (const facts of state.facts) { + for (const [name, rule] of expectedPackageDependencies(facts)) { + const actual = declaredSections(facts.manifest, name) + if (rule.section === 'peer-dev') { + if (actual.length === 2 + && actual.includes('peerDependencies') + && actual.includes('devDependencies') + && section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE + && section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE + && facts.manifest.peerDependenciesMeta?.[name] === undefined) continue + violations.push( + `${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`, + ) + continue + } + const expectedSection = rule.section + const range = section(facts.manifest, expectedSection)[name] + if (actual.length === 1 + && actual[0] === expectedSection + && (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue + violations.push( + `${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only` + + (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '') + + `; found ${describeSections(actual)}`, + ) + } + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) { + for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) { + if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue + violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`) + } + } + for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) { + if (facts.manifest.peerDependencies?.[name] === undefined) { + violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`) + } + } + } + return [...new Set(violations)].sort() +} + +function deleteDependency( + manifest: PackageDependencyManifest, + sectionName: DependencySection, + name: string, +): void { + const dependencies = manifest[sectionName] + if (dependencies?.[name] === undefined) return + const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name)) + if (Object.keys(retained).length > 0) { + manifest[sectionName] = retained + return + } + switch (sectionName) { + case 'dependencies': delete manifest.dependencies; break + case 'devDependencies': delete manifest.devDependencies; break + case 'optionalDependencies': delete manifest.optionalDependencies; break + case 'peerDependencies': delete manifest.peerDependencies; break + } +} + +function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void { + if (manifest.peerDependenciesMeta?.[name] === undefined) return + const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta) + .filter(([key]) => key !== name)) + if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained + else delete manifest.peerDependenciesMeta +} + +function preferredRange( + facts: PackageDependencyFacts, + name: string, + target: ExpectedPackageDependency['section'], +): string | undefined { + if (facts.workspaceNames.has(name)) return WORKSPACE_RANGE + const order: readonly DependencySection[] = target === 'dependencies' + ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] + : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies'] + return order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined) +} + +/** Apply the dependency policy to one in-memory manifest. */ +export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void { + for (const [name, rule] of expectedPackageDependencies(facts)) { + if (rule.section === 'peer-dev') { + for (const sectionName of ['dependencies', 'optionalDependencies'] as const) { + deleteDependency(facts.manifest, sectionName, name) + } + mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE + mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE + deletePeerMeta(facts.manifest, name) + continue + } + const range = preferredRange(facts, name, rule.section) + if (range === undefined) continue + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) { + if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name) + } + mutableSection(facts.manifest, rule.section)[name] = range + deletePeerMeta(facts.manifest, name) + } + for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) { + if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name) + } + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) { + for (const name of Object.keys(section(facts.manifest, sectionName))) { + if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE + } + } +} + +/** Repair every covered manifest and return repository-relative changed paths. */ +export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] { + if (state.policyViolations.length > 0) return [] + const changed: string[] = [] + for (const facts of state.facts) { + const before = `${JSON.stringify(facts.manifest, null, 2)}\n` + repairPackageDependencyManifest(facts) + const after = `${JSON.stringify(facts.manifest, null, 2)}\n` + if (after === before) continue + writeFileSync(resolve(root, facts.manifestPath), after) + changed.push(facts.manifestPath) + } + return changed.sort() +} + +function main(): void { + const root = resolve(import.meta.dirname, '..') + let state = readPackageDependencyState(root) + const fix = process.argv.includes('--fix') + if (fix) { + const changed = fixPackageDependencies(root, state) + console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`) + state = readPackageDependencyState(root) + } + const violations = collectPackageDependencyViolations(state) + if (violations.length > 0) { + console.error(`${GATE}: ${String(violations.length)} violation(s):`) + for (const violation of violations) console.error(` ${violation}`) + process.exitCode = 1 + return + } + const roles = Object.groupBy(state.facts, fact => fact.role) + console.log( + `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy` + + ` (${String(roles['client-host']?.length ?? 0)} Client/Host,` + + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`, + ) + if (fix) { + for (const line of formatManagedRuntimeDependencies(state)) console.log(line) + } +} + +if (import.meta.main) main() From e4406344560c78e8210246430163de40056259dd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:56 +0800 Subject: [PATCH 073/429] docs: define published dependency faces --- ...ient-shells-and-dynamic-packages.i18n.yaml | 4 +- ...8-15-client-shells-and-dynamic-packages.md | 6 +- ...5-client-shells-and-dynamic-packages.zh.md | 6 +- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 6 ++ .../2026-08-10-npm-release-sequences.zh.md | 6 ++ ...08-26-published-dependency-faces.i18n.yaml | 6 ++ .../2026-08-26-published-dependency-faces.md | 55 +++++++++++++++++++ ...026-08-26-published-dependency-faces.zh.md | 55 +++++++++++++++++++ packages/client/AGENTS.md | 12 ++-- 10 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md create mode 100644 .agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml index 3baf886df0..9898d344d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.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-15-client-shells-and-dynamic-packages.md -2026-08-15-client-shells-and-dynamic-packages.md: 016314d10f55e0b590e98944ca417bae658ab56a -2026-08-15-client-shells-and-dynamic-packages.zh.md: 4e0277d1becab8467521dc21d0e5b7509d1ee993 +2026-08-15-client-shells-and-dynamic-packages.md: 1d67c778b6a06849324dd6095a98d57dc41b94f9 +2026-08-15-client-shells-and-dynamic-packages.zh.md: db1e4e7e7b319c283ae39a94535d88d4dc71d60a diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md index 016314d10f..1d67c778b6 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md @@ -57,11 +57,11 @@ After the `immediately` tier has registered its factories, the kernel creates al ### Dependency declarations -Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`. A dynamic package that imports, re-exports, augments, or names an internal dynamic package in `dsh.client.inject` keeps that package as matching peer and development dependencies. Static client inputs and React modules are development-only inputs for a dynamic package because the shell supplies their runtime identities. +Every Client package keeps Cordis in matching `peerDependencies` and `devDependencies`; Cordis is its only peer. Browser imports, type references, module augmentations, and `dsh.client.inject` are development inputs because the Client build and shipped profile supply their runtime identities. A package that also publishes a Host entry keeps that entry's runtime value imports in `dependencies`. [Published dependency faces](../process/2026-08-26-published-dependency-faces.md) owns package discovery, exceptions, and the explicit Host roster. Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a private implementation, while a `staticLinked` library retains its bare import for the final host. Each build face decides externality independently from npm sections. Published file lists cover every runtime entry, relative asset, and declaration file reached by the artifact. -`verify-client-packages` enforces these classifications, dependency sections, build forms, parser-preload alignment, shared-module requests, and module-graph acyclicity. The repository publint pass enforces publication closure. The verifier's `--fix` mode repairs only unambiguous manifest drift. +`verify-package-dependencies` enforces and repairs dependency sections. `verify-client-packages` enforces build forms, parser-preload alignment, shared-module requests, and module-graph acyclicity. The repository publint pass enforces publication closure. ## Alternatives considered @@ -77,7 +77,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a ## Consequences -Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance. +Bundle contents stay stable when an internal DSH relationship is development-only, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance. The shipped profile owns the complete Client package roster, so individual Client packages do not ask npm to solve the same graph again through peer placement. The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan. diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md index 4e0277d1be..db1e4e7e7b 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md @@ -57,11 +57,11 @@ Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外 ### 依赖声明 -每个 client 包都把 Cordis 保持为 matching `peerDependencies` 和 `devDependencies`。动态包若 import、re-export、augment 内部动态包,或在 `dsh.client.inject` 中命名它,就把该包保持为 matching peer 与开发依赖。静态 client 输入和 React 模块对动态包只是开发依赖,因为外壳提供其运行期身份。 +每个 Client 包都把 Cordis 保持为范围一致的 `peerDependencies` 和 `devDependencies`;Cordis 是唯一的 peer。Browser import、类型引用、模块扩充与 `dsh.client.inject` 都是开发输入,因为 Client 构建与发布 profile 会提供其运行期身份。同时发布 Host 入口的包把该入口的运行期 value import 放在 `dependencies`。[发布依赖门面](../process/2026-08-26-published-dependency-faces.zh.md)负责包发现、例外与显式 Host 名册。 普通安装库仍放在 `dependencies`:动态构建可以内联私有实现,而 `staticLinked` 库会保留 bare import 交给最终宿主。各构建 face 独立决定 external,不由 npm 区段推导。发布文件列表覆盖产物实际可达的每个运行期入口、相对资产和声明文件。 -`verify-client-packages` 会检查这些分类、依赖区段、构建形态、parser preload 对齐、共享模块请求和模块图无环性。仓库 publint pass 负责检查发布闭包。该验证器的 `--fix` 模式只修复无歧义的 manifest 漂移。 +`verify-package-dependencies` 检查并修复依赖区段。`verify-client-packages` 检查构建形态、parser preload 对齐、共享模块请求和模块图无环性。仓库 publint pass 负责检查发布闭包。 ## Alternatives considered @@ -77,7 +77,7 @@ Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外 ## Consequences -Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。 +内部 DSH 关系仅放在开发区段时,bundle 内容仍保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。发布 profile 拥有完整 Client 包名册,因此各 Client 包不再要求 npm 通过 peer placement 重复求解同一张图。 启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index da8882e163..1cbf4b3a85 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: 014bfe3abb2548a369cbfc6a5f11263303e0656a -2026-08-10-npm-release-sequences.zh.md: a67311afd93d3f4f9f0a396237c9ce0b04db0a06 +2026-08-10-npm-release-sequences.md: f83bce914bae4cfc5a047bf975b623579dc9156f +2026-08-10-npm-release-sequences.zh.md: c24e5c0a29af2f875136d439ce3fe06577c061b1 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index 014bfe3abb..f83bce914b 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -80,6 +80,12 @@ Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substit `scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason. +### Published dependency faces use an explicit policy + +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) classifies workspace relationships by their published Client and Host use, keeps only Cordis as a peer in covered packages, and applies a small explicit Host roster. [Published dependency faces and bounded peer relays](2026-08-26-published-dependency-faces.md) owns the selection rules and rationale. + +`pnpm run benchmark:npm-resolution` measures this graph manually with the installed npm executable. `pnpm run benchmark:npm-resolution:next` additionally tries each reachable unconfigured Host package and serially remeasures the leading candidates. Both commands use a loopback metadata registry and reject archive requests, so their duration excludes package downloads. Neither command is an aggregate gate because scheduler load and metadata completion order make wall-clock thresholds nondeterministic. + ### An optional dependency is never loaded at module scope A dependency in `optionalDependencies`, or a peer carrying `peerDependenciesMeta..optional`, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. The failure appears only in an installed tree missing that package, and no test here constructs one: a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index a67311afd9..c24e5c0a29 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -80,6 +80,12 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 `scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。 +### 发布依赖门面使用显式策略 + +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 按已发布的 Client 与 Host 用法分类 workspace 关系,让受管包只保留 Cordis peer,并应用一份较小的显式 Host 名册。[发布依赖门面与有限 peer 中继](2026-08-26-published-dependency-faces.zh.md)记录选包规则与理由。 + +`pnpm run benchmark:npm-resolution` 使用当前安装的 npm 手动测量该依赖图。`pnpm run benchmark:npm-resolution:next` 还会逐个尝试每个可达且未配置的 Host 包,再串行复测领先候选。两个命令都使用回环 metadata registry 并拒绝包归档请求,因此耗时不包含包下载。调度器负载与 metadata 完成顺序会使墙钟阈值失去确定性,所以两个命令都不进入聚合门禁。 + ### optional 依赖绝不在模块作用域被加载 `optionalDependencies` 里的依赖,或带 `peerDependenciesMeta..optional` 的 peer,在安装出来的树里可以不存在——这份「可以不存在」正是 optional 的全部承诺。而静态 import 在引入方模块加载时就求值,于是一个缺失的包不再表现为「这个能力不可用」,而是变成所有能走到该模块的代码的加载失败。这种失败只在「缺了该包的安装树」里出现,而本仓没有任何测试构造这种树:workspace 安装总是把每个包都装上,所以单测、快照、打包安装探针全都会过,而那个拒绝了这个 optional peer 的消费者拿到的却是坏的包。 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml new file mode 100644 index 0000000000..2056e59e6e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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/process/2026-08-26-published-dependency-faces.md +2026-08-26-published-dependency-faces.md: 437044a0dca9c5c2b2b509d62de3983a38f396b1 +2026-08-26-published-dependency-faces.zh.md: b8398a9168889871f451bdaeeb225d850eebeb98 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md new file mode 100644 index 0000000000..437044a0dc --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -0,0 +1,55 @@ +# Agent Note: Published dependency faces and bounded peer relays + +Status: implemented + +English | [中文](2026-08-26-published-dependency-faces.zh.md) + +## Problem + +A package may contain a browser bundle, a Host entry, shared TypeScript declarations, and Cordis injection metadata. Encoding all of those relationships as required npm peers made the published CLI expensive to install: npm installs peers automatically and repeatedly evaluates placement through deep, converging peer paths. Changing ranges or making the peers optional did not remove that traversal. + +The package that chooses a Client build input is the shipped profile, while a Host value import is loaded by Node from the importing package. Those relationships need different npm sections. Applying one rule to every Host package would reduce the graph but would also create a large migration with no corresponding installation benefit. + +## Decision + +### Package selection + +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. The directory includes static Client inputs without a dynamic row, while `dsh.client` identifies dynamically loaded packages outside it; a `"./client"` export alone is an API and does not select npm dependency policy. Every selected package's Host entry is scanned, including entries under `packages/client/`. + +[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller`, because adding it back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds. + +Host-only packages join the same policy through a separate explicit list. The list contains `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-session`; source imports do not expand it. + +### Dependency sections + +Every covered package keeps `@deepseek-ai/cordis` in matching `peerDependencies` and `devDependencies`. Cordis is the shared plugin runtime whose identity the application controls. + +A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies`. Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`. + +The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. Its `--fix` mode performs only the section and range changes implied by this classification and removes stale peer metadata. + +### Performance verification + +[`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) applies the current policy to an in-memory local registry, measures the current CLI graph, and tries every reachable unconfigured Host package one at a time. Concurrent runs provide a coarse shortlist; finalists run serially because npm's peer-placement search can take different paths when metadata request completion order changes. + +The benchmark is manual rather than a CI gate. It performs metadata-only installs in fresh consumers, so its relative results identify peer relays without measuring registry latency or archive downloads. + +## Alternatives considered + +**Keep internal relationships as peers.** npm must place and validate each required peer along converging ancestry paths, which recreates the reported install-time failure even when all internal versions are compatible. + +**Use the `"./client"` export as the Client-face roster.** A package may publish Client-facing types or a browser API without contributing a dynamically loaded row. Selecting that package broadens the migration to unrelated Host packages such as Goal, Session Title, and Todo. `dsh.client` identifies dynamic rows, while the `packages/client/` directory independently covers static Client inputs. + +**Flatten every Host package.** This removes more peer work but expands the migration to packages whose individual benchmark result is negligible. The explicit Host list preserves the remaining peer contracts until measurement justifies another entry. + +**Move every Client-related declaration to development-only.** A dual-face package's Host value imports remain real Node loads. Omitting them from the published dependency graph makes the package depend on accidental hoisting by a profile. + +**Enforce a wall-clock threshold in CI.** Resolver time varies with machine load and metadata completion order. Deterministic manifest classification belongs in CI; timing remains a maintainer benchmark. + +## Consequences + +The published dependency graph follows artifact ownership instead of source-directory coupling. Client bundles and shipped profiles provide browser identities, Host modules install the values they load, and only Cordis remains a repository-wide peer for covered packages. + +Moving a public type-only relationship to `devDependencies` means a standalone TypeScript consumer must install the referenced type package when it consumes that declaration. The shipped profiles install the complete supported package family; supporting independently assembled TypeScript consumers would require a different policy. + +The explicit overrides and Host list are reviewable decisions. Adding an exception changes the installed graph and requires the focused verifier tests plus a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md new file mode 100644 index 0000000000..b8398a9168 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 发布依赖门面与有限 peer 中继 + +Status: implemented + +[English](2026-08-26-published-dependency-faces.md) | 中文 + +## 问题 + +一个包可能同时包含浏览器 bundle、Host 入口、共享 TypeScript 声明和 Cordis 注入元数据。把这些关系全部编码成必需 npm peer 会使已发布 CLI 的安装代价过高:npm 会自动安装 peer,并沿深层、反复汇合的 peer 路径重复执行放置检查。修改版本范围或把 peer 标成 optional 都不会消除这类遍历。 + +Client 构建输入由发布 profile 选择,而 Host value import 由导入它的包通过 Node 加载;两者需要不同的 npm 区段。把规则应用到每个 Host 包虽然也能缩小依赖图,却会制造一个没有对应安装收益的大范围迁移。 + +## 决策 + +### 包选择 + +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。该目录包含没有动态 row 的静态 Client 输入,而 `dsh.client` 标识目录外的动态装载包;仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。每个选中包的 Host 入口都会接受扫描,包括 `packages/client/` 下的入口。 + +[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller`,因为把它加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。 + +Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@deepseek-ai/dsh-llm` 和 `@deepseek-ai/dsh-session`;源码 import 不会自动扩大列表。 + +### 依赖区段 + +每个受管包都把 `@deepseek-ai/cordis` 保持在范围一致的 `peerDependencies` 和 `devDependencies` 中。Cordis 是由应用控制身份的共享插件运行时。 + +Host 入口闭包中的运行期 value import 所到达的 workspace 包只属于 `dependencies`。Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。 + +验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。其 `--fix` 模式只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 + +### 性能验证 + +[`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 把当前策略应用到内存中的本地 registry,测量当前 CLI 依赖图,并逐个尝试每个可达且未配置的 Host 包。并发运行用于得到粗筛名单;由于 metadata 请求的完成顺序会让 npm 的 peer 放置搜索走不同路径,最终候选会串行复测。 + +Benchmark 是手动诊断工具而非 CI 门禁。它在全新 consumer 中执行仅 metadata 的安装,因此相对结果可以定位 peer 中继,但不测量 registry 延迟或包归档下载。 + +## 考虑过的替代方案 + +**把内部关系继续保留为 peer。** npm 必须沿汇合的祖先路径放置并验证每个必需 peer;即使内部版本全部兼容,也会重新产生已报告的安装耗时问题。 + +**用 `"./client"` export 作为 Client 门面名册。** 包可能发布 Client 类型或浏览器 API,却不贡献动态装载 row。选中这类包会把迁移扩大到 Goal、Session Title 和 Todo 等无关 Host 包。`dsh.client` 标识动态 row,而 `packages/client/` 目录独立覆盖静态 Client 输入。 + +**拍平全部 Host 包。** 这会移除更多 peer 工作,却把迁移扩大到单包 benchmark 收益可忽略的包。显式 Host 列表会保留其余 peer 约束,直到测量结果证明应增加新成员。 + +**把所有 Client 相关声明都改为仅开发依赖。** 双面包的 Host value import 仍是实际的 Node 加载;从发布依赖图中删掉它们,会让包依赖 profile 的偶然提升。 + +**在 CI 中强制墙钟阈值。** Resolver 耗时会随机器负载和 metadata 完成顺序变化。确定性的 manifest 分类进入 CI,耗时测量保留为维护者 benchmark。 + +## 结果 + +发布依赖图按产物归属而不是源码目录耦合分类。Client bundle 与发布 profile 提供浏览器运行时身份,Host 模块安装自己加载的实体,而 Cordis 是受管包中唯一的全仓 peer。 + +把公开纯类型关系放进 `devDependencies`,意味着独立 TypeScript 消费者在使用该声明时必须自行安装被引用的类型包。发布 profile 会安装完整的受支持包族;若要支持独立组装的 TypeScript 消费者,需要另一套策略。 + +显式 override 与 Host 列表都是需要评审的决策。增加例外会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 29a111effe..cf7964203b 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -56,13 +56,13 @@ Non-negotiables across the layers: ## Dependency declaration -Npm sections describe installation and development relationships; each build face independently decides what its artifact contains. [`verify-client-packages`](../../scripts/verify-client-packages.ts) checks the client-specific rules and can repair unambiguous manifest drift with `--fix`. +Npm sections describe installation and development relationships; each build face independently decides what its artifact contains. [`verify-package-dependencies`](../../scripts/verify-package-dependencies.ts) checks and repairs these rules; [`verify-client-packages`](../../scripts/verify-client-packages.ts) owns Client loading and module requests. 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract. -2. **A dynamic package declares internal dynamic relationships as peer plus dev.** Production source imports, re-exports, module augmentations, and type-only references to an `@deepseek-ai/dsh-*` package count, as does a package named by `dsh.client.inject`. A test-only internal dependency stays dev-only. -3. **Static client inputs are dev-only for a dynamic consumer.** A package without `dsh.client`, plus the React modules seeded by the web shell, belongs only in the consumer's `devDependencies`; it never belongs in that dynamic package's `dependencies` or `peerDependencies`. `packages/client/web` likewise keeps Loader, modules, and static UI inputs as development inputs; Cordis remains peer plus dev. -4. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. A dynamic package never puts an `@deepseek-ai/dsh-*` package in `dependencies`. -5. **Every peer has a matching development range.** npm dependency and peer cycles are allowed; only the synchronous module-request graph has the separate acyclicity rule below. +2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy. +3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. +4. **Host value imports are ordinary dependencies.** A workspace value reached from the package's Host entry belongs only in `dependencies`; it is not a peer merely because another package may also install it. +5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. @@ -140,7 +140,7 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph). 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. -6. **Declaration decisions**, each settled by [dependency declaration](#dependency-declaration) and [shared modules](#shared-modules-and-the-module-graph): does the package ship a `./client` export; which non-baseline value imports require `dsh.client.external`; which dynamic value dependencies are peer plus dev; which static compile inputs are dev-only; and whether `files` covers every relative runtime import and emitted asset. +6. **Declaration decisions**, each settled by [dependency declaration](#dependency-declaration) and [shared modules](#shared-modules-and-the-module-graph): does the package ship a `./client` export; which non-baseline value imports require `dsh.client.external`; which Host value imports are ordinary dependencies; which Browser and type inputs are dev-only; and whether `files` covers every relative runtime import and emitted asset. ## New component checklist From c55beac34adfe8696bf949704fa2327202ecff7e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:06:46 +0800 Subject: [PATCH 074/429] feat: verify Host dependency export identity --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 14 +- ...026-08-26-published-dependency-faces.zh.md | 14 +- packages/client/AGENTS.md | 2 +- .../benchmark-next-package-dependency.spec.ts | 10 + scripts/verify-client-packages.ts | 11 + scripts/verify-package-dependencies.spec.ts | 195 +++++++++++- scripts/verify-package-dependencies.ts | 298 +++++++++++++++--- 8 files changed, 490 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index 2056e59e6e..433b12fa8c 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 437044a0dca9c5c2b2b509d62de3983a38f396b1 -2026-08-26-published-dependency-faces.zh.md: b8398a9168889871f451bdaeeb225d850eebeb98 +2026-08-26-published-dependency-faces.md: 04b02afe372b2f3d90729f17e837b40ff1e0e6fa +2026-08-26-published-dependency-faces.zh.md: 468fb443e2e94bf23f8c61201f43c2a82f7f509b diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 437044a0dc..04b02afe37 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -14,9 +14,9 @@ The package that chooses a Client build input is the shipped profile, while a Ho ### Package selection -[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. The directory includes static Client inputs without a dynamic row, while `dsh.client` identifies dynamically loaded packages outside it; a `"./client"` export alone is an API and does not select npm dependency policy. Every selected package's Host entry is scanned, including entries under `packages/client/`. +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy. -[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller`, because adding it back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds. +[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller` and `@deepseek-ai/dsh-api-workspace-controller`. Adding Session Controller back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds. Host-only packages join the same policy through a separate explicit list. The list contains `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-session`; source imports do not expand it. @@ -24,9 +24,11 @@ Host-only packages join the same policy through a separate explicit list. The li Every covered package keeps `@deepseek-ai/cordis` in matching `peerDependencies` and `devDependencies`. Cordis is the shared plugin runtime whose identity the application controls. -A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies`. Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`. +A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies` when every imported runtime export appears in the policy's `safeHostDependencyExports` table. An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports present in neither table; namespace, dynamic, and side-effect imports remain unbounded and cannot enter either table. -The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. Its `--fix` mode performs only the section and range changes implied by this classification and removes stale peer metadata. +Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`. + +The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata. ### Performance verification @@ -48,8 +50,8 @@ The benchmark is manual rather than a CI gate. It performs metadata-only install ## Consequences -The published dependency graph follows artifact ownership instead of source-directory coupling. Client bundles and shipped profiles provide browser identities, Host modules install the values they load, and only Cordis remains a repository-wide peer for covered packages. +The published dependency graph follows artifact ownership instead of source-directory coupling. Client bundles and shipped profiles provide browser identities, Host modules install duplicate-safe values they load, and Cordis plus explicitly peer-required Host exports retain shared package instances. Moving a public type-only relationship to `devDependencies` means a standalone TypeScript consumer must install the referenced type package when it consumes that declaration. The shipped profiles install the complete supported package family; supporting independently assembled TypeScript consumers would require a different policy. -The explicit overrides and Host list are reviewable decisions. Adding an exception changes the installed graph and requires the focused verifier tests plus a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise. +The explicit overrides, Host list, and export classifications are reviewable decisions. Class constructors used by `instanceof`, symbols, and accessors for module-private registries require peers when their identity or state crosses package boundaries; being a value import alone does not make an export duplicate-safe. Changing a classification changes the installed graph and requires the focused verifier tests plus a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index b8398a9168..468fb443e2 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -14,9 +14,9 @@ Client 构建输入由发布 profile 选择,而 Host value import 由导入它 ### 包选择 -[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。该目录包含没有动态 row 的静态 Client 输入,而 `dsh.client` 标识目录外的动态装载包;仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。每个选中包的 Host 入口都会接受扫描,包括 `packages/client/` 下的入口。 +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。 -[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller`,因为把它加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。 +[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller` 和 `@deepseek-ai/dsh-api-workspace-controller`。把 Session Controller 加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。 Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@deepseek-ai/dsh-llm` 和 `@deepseek-ai/dsh-session`;源码 import 不会自动扩大列表。 @@ -24,9 +24,11 @@ Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@d 每个受管包都把 `@deepseek-ai/cordis` 保持在范围一致的 `peerDependencies` 和 `devDependencies` 中。Cordis 是由应用控制身份的共享插件运行时。 -Host 入口闭包中的运行期 value import 所到达的 workspace 包只属于 `dependencies`。Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。 +Host 入口闭包中的运行期 value import 所到达的 workspace 包,只有在每个运行期导出都列入策略的 `safeHostDependencyExports` 表时才只属于 `dependencies`。constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。表的每个 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝两个表都未收录的导出;namespace、dynamic 和 side-effect import 无法限定导出范围,因此不能进入任一表。 -验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。其 `--fix` 模式只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 +Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。 + +验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 ### 性能验证 @@ -48,8 +50,8 @@ Benchmark 是手动诊断工具而非 CI 门禁。它在全新 consumer 中执 ## 结果 -发布依赖图按产物归属而不是源码目录耦合分类。Client bundle 与发布 profile 提供浏览器运行时身份,Host 模块安装自己加载的实体,而 Cordis 是受管包中唯一的全仓 peer。 +发布依赖图按产物归属而不是源码目录耦合分类。Client bundle 与发布 profile 提供浏览器运行时身份,Host 模块安装自己加载的可重复实体,而 Cordis 和显式标为 peer-required 的 Host 导出继续共享包实例。 把公开纯类型关系放进 `devDependencies`,意味着独立 TypeScript 消费者在使用该声明时必须自行安装被引用的类型包。发布 profile 会安装完整的受支持包族;若要支持独立组装的 TypeScript 消费者,需要另一套策略。 -显式 override 与 Host 列表都是需要评审的决策。增加例外会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。 +显式 override、Host 列表与导出分类都是需要评审的决策。当 class constructor、symbol 和访问模块私有 registry 的函数跨包传递身份或状态时,它们要求 peer;仅仅属于 value import 并不能证明导出可重复安装。修改分类会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index cf7964203b..6ef0c5e42c 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -61,7 +61,7 @@ Npm sections describe installation and development relationships; each build fac 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract. 2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy. 3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. -4. **Host value imports are ordinary dependencies.** A workspace value reached from the package's Host entry belongs only in `dependencies`; it is not a peer merely because another package may also install it. +4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests. 5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. diff --git a/scripts/benchmark-next-package-dependency.spec.ts b/scripts/benchmark-next-package-dependency.spec.ts index 9248a3288e..2a0ee10189 100644 --- a/scripts/benchmark-next-package-dependency.spec.ts +++ b/scripts/benchmark-next-package-dependency.spec.ts @@ -63,6 +63,16 @@ describe('next package benchmark graph', () => { ['@f/types', ['packages/g/probe/src/types.ts']], ]), hostRuntimeSourceUses: new Map([['@f/runtime', ['packages/g/probe/src/index.ts']]]), + hostRuntimeExportUses: [{ + packageName: '@f/runtime', + specifier: '@f/runtime', + exportName: 'runtimeValue', + sourcePath: 'packages/g/probe/src/index.ts', + line: 1, + column: 10, + sourceLine: "import { runtimeValue } from '@f/runtime'", + }], + peerRequiredHostDependencies: new Set(), clientInject: new Set(), } const index = new Map>([ diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index 94e70497ae..9dfb1599cb 100644 --- a/scripts/verify-client-packages.ts +++ b/scripts/verify-client-packages.ts @@ -100,6 +100,17 @@ export function collectLocalSourceSpecifiers(path: string, source: string): Set< return collectSourceFileUses(sourceFile, false, 'local') } +/** + * Collect relative module specifiers retained by one production source file. + * @param path - File path used to select TypeScript's parser mode. + * @param source - Source text to inspect. + * @returns Relative imports, exports, and requires that survive compilation. + */ +export function collectRuntimeLocalSourceSpecifiers(path: string, source: string): Set { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + return collectSourceFileUses(sourceFile, true, 'local') +} + function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean { const clause = node.importClause if (clause === undefined) return true diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 253a5ea7c3..23c1bbca69 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -7,10 +7,13 @@ import { type PackageDependencyPolicy, } from './package-dependency-policy.ts' import { + collectHostDependencyExportPolicyViolations, collectPackageDependencyViolations, + collectRuntimeSourceExportUses, discoverPackageDependencyScope, fixPackageDependencies, formatManagedRuntimeDependencies, + formatPeerRequiredRuntimeDependencies, readPackageDependencyFacts, repairPackageDependencyManifest, type PackageDependencyFacts, @@ -43,6 +46,8 @@ function policy(fields: Partial = {}): PackageDependenc clientFaceInclude: [], clientFaceExclude: [], hostPackages: [], + safeHostDependencyExports: {}, + peerRequiredHostExports: {}, ...fields, } } @@ -66,6 +71,16 @@ function facts(manifest: PackageDependencyManifest): PackageDependencyFacts { hostRuntimeSourceUses: new Map([ ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']], ]), + hostRuntimeExportUses: [{ + packageName: '@deepseek-ai/dsh-runtime', + specifier: '@deepseek-ai/dsh-runtime', + exportName: 'runtimeValue', + sourcePath: 'packages/core/probe/src/index.ts', + line: 1, + column: 10, + sourceLine: "import { runtimeValue } from '@deepseek-ai/dsh-runtime'", + }], + peerRequiredHostDependencies: new Set(), clientInject: new Set(), } } @@ -74,11 +89,19 @@ describe('package dependency scope', () => { it('keeps the measured Host relay roster explicit', () => { expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ '@deepseek-ai/dsh-api-session-controller', + '@deepseek-ai/dsh-api-workspace-controller', ]) expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([ '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-session', ]) + expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default']) + expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([ + 'carrierKeyOf', 'scopeOf', 'scopeTarget', + ]) + expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toEqual([ + 'TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods', + ]) }) it('discovers the Client directory, dsh.client declarations, and configured Host packages', () => { @@ -103,7 +126,7 @@ describe('package dependency scope', () => { ['@f/dual', 'client-host'], ['@f/forced-client', 'client-host'], ['@f/dynamic-client', 'client-host'], - ['@f/static', 'client-host'], + ['@f/static', 'client-only'], ['@f/host', 'configured-host'], ]) }) @@ -129,6 +152,49 @@ describe('package dependency scope', () => { expect.stringContaining('unknown release package @f/missing'), ])) }) + + it('rejects stale, duplicate, and unbounded safe Host export entries', () => { + const consumer = pkg('@f/consumer', 'packages/core/consumer/package.json') + const provider = pkg('@f/provider', 'packages/core/provider/package.json') + const workspaceNames = new Set([CORDIS, consumer.name, provider.name]) + const consumerFacts: PackageDependencyFacts = { + manifestPath: consumer.manifestPath, + role: 'configured-host', + manifest: consumer.manifest, + workspaceNames, + allSourceUses: new Map(), + hostRuntimeSourceUses: new Map([[provider.name, ['packages/core/consumer/src/index.ts']]]), + hostRuntimeExportUses: [{ + packageName: provider.name, + specifier: `${provider.name}/api`, + exportName: 'safeValue', + sourcePath: 'packages/core/consumer/src/index.ts', + line: 1, + column: 10, + sourceLine: "import { safeValue } from '@f/provider/api'", + }], + peerRequiredHostDependencies: new Set(), + clientInject: new Set(), + } + + expect(collectHostDependencyExportPolicyViolations( + [consumerFacts], + workspaceNames, + { + safeHostDependencyExports: { + [`${provider.name}/api`]: ['safeValue', 'safeValue', '*', 'staleValue'], + }, + peerRequiredHostExports: { + [`${provider.name}/api`]: ['safeValue'], + }, + }, + )).toEqual(expect.arrayContaining([ + expect.stringContaining('export safeValue more than once'), + expect.stringContaining('cannot classify unbounded'), + expect.stringContaining('unused @f/provider/api export staleValue'), + expect.stringContaining('appears in both Host export classifications'), + ])) + }) }) describe('face-aware source classification', () => { @@ -142,9 +208,11 @@ describe('face-aware source classification', () => { 'packages/g/dual/src/index.ts': [ "import { value } from '@f/runtime'", "import type { Shared } from '@f/types'", + "import type { Hidden } from './types.ts'", "export { nested } from './nested.ts'", ].join('\n'), 'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'", + 'packages/g/dual/src/types.ts': "import { hidden } from '@f/hidden'; export type Hidden = typeof hidden", 'packages/g/dual/src/client/index.ts': "import { browser } from '@f/browser'", } for (const [path, source] of Object.entries(files)) { @@ -153,11 +221,63 @@ describe('face-aware source classification', () => { } const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([ - CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/browser', '@f/injected', + CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected', ])) expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime']) - expect([...found.allSourceUses.keys()].sort()).toEqual(['@f/browser', '@f/nested', '@f/runtime', '@f/types']) + expect(found.hostRuntimeExportUses).toEqual([ + { + packageName: '@f/nested', + specifier: '@f/nested', + exportName: 'nested', + sourcePath: 'packages/g/dual/src/nested.ts', + line: 1, + column: 10, + sourceLine: "export { nested } from '@f/nested'", + }, + { + packageName: '@f/runtime', + specifier: '@f/runtime', + exportName: 'value', + sourcePath: 'packages/g/dual/src/index.ts', + line: 1, + column: 10, + sourceLine: "import { value } from '@f/runtime'", + }, + ]) + expect([...found.allSourceUses.keys()].sort()).toEqual([ + '@f/browser', '@f/hidden', '@f/nested', '@f/runtime', '@f/types', + ]) + }) + + it('identifies exact runtime exports without treating type imports as values', () => { + const source = [ + "import defaultValue, { value as local, type Kind } from '@f/root'", + "import * as namespace from '@f/namespace'", + "import '@f/effect'", + "import type { TypeOnly } from '@f/types'", + "export { source as renamed, type SourceType } from '@f/reexport'", + "export * from '@f/star'", + "void import('@f/dynamic')", + "void require('@f/required')", + 'void defaultValue; void local; void namespace', + ].join('\n') + const uses = collectRuntimeSourceExportUses('probe.ts', source) + expect(uses.map(({ specifier, exportName }) => ({ specifier, exportName }))).toEqual([ + { specifier: '@f/dynamic', exportName: '*' }, + { specifier: '@f/effect', exportName: '(side effect)' }, + { specifier: '@f/namespace', exportName: '*' }, + { specifier: '@f/reexport', exportName: 'source' }, + { specifier: '@f/required', exportName: '*' }, + { specifier: '@f/root', exportName: 'default' }, + { specifier: '@f/root', exportName: 'value' }, + { specifier: '@f/star', exportName: '*' }, + ]) + expect(uses.find(use => use.specifier === '@f/root' && use.exportName === 'value')).toMatchObject({ + line: 1, + column: 24, + sourceLine: "import defaultValue, { value as local, type Kind } from '@f/root'", + }) }) }) @@ -186,8 +306,73 @@ describe('dependency sections', () => { expect(formatManagedRuntimeDependencies({ facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames, })).toEqual([ - 'verify-package-dependencies: 1 managed Host runtime dependency edge(s) remain in dependencies across 1 package(s):', - ' @deepseek-ai/dsh-probe: @deepseek-ai/dsh-runtime', + 'verify-package-dependencies: 1 managed Host runtime edge(s) remain in dependencies across 1 package(s):', + ' @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue', + ]) + }) + + it('reports an unapproved Host runtime export without rewriting its dependency section', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' }, + peerDependencies: { [CORDIS]: 'workspace:^' }, + } + const subject = facts(manifest) + const safetyViolations = collectHostDependencyExportPolicyViolations( + [subject], + subject.workspaceNames, + { safeHostDependencyExports: {}, peerRequiredHostExports: {} }, + ) + const state = { + facts: [subject], packages: [], policyViolations: safetyViolations, workspaceNames: subject.workspaceNames, + } + + expect(safetyViolations).toEqual([ + 'packages/core/probe/src/index.ts:1:10: @deepseek-ai/dsh-runtime#runtimeValue is not classified as ' + + 'safe or peer-required — import { runtimeValue } from \'@deepseek-ai/dsh-runtime\'', + ]) + expect(fixPackageDependencies('/unused', state)).toEqual([]) + expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' }) + }) + + it('keeps an edge as a peer when one imported export requires shared identity', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' }, + peerDependencies: { [CORDIS]: 'workspace:^' }, + } + const subject: PackageDependencyFacts = { + ...facts(manifest), + peerRequiredHostDependencies: new Set(['@deepseek-ai/dsh-runtime']), + } + expect(collectHostDependencyExportPolicyViolations( + [subject], + subject.workspaceNames, + { + safeHostDependencyExports: {}, + peerRequiredHostExports: { + '@deepseek-ai/dsh-runtime': ['runtimeValue'], + }, + }, + )).toEqual([]) + + repairPackageDependencyManifest(subject) + expect(manifest.dependencies).toBeUndefined() + expect(manifest.peerDependencies).toMatchObject({ + [CORDIS]: 'workspace:^', + '@deepseek-ai/dsh-runtime': 'workspace:^', + }) + expect(manifest.devDependencies).toMatchObject({ + [CORDIS]: 'workspace:^', + '@deepseek-ai/dsh-runtime': 'workspace:^', + }) + expect(formatPeerRequiredRuntimeDependencies({ + facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames, + })).toEqual([ + 'verify-package-dependencies: 1 Host runtime edge(s) remain in peerDependencies because their exports require shared identity across 1 package(s):', + ' @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue', ]) }) diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index fda6184864..f8ac1cf3dd 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -2,14 +2,14 @@ import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path' +import ts from 'typescript' import { hasClientDeclaration, PACKAGE_DEPENDENCY_POLICY, type PackageDependencyPolicy, } from './package-dependency-policy.ts' import { - collectLocalSourceSpecifiers, - collectRuntimeSourcePackageUses, + collectRuntimeLocalSourceSpecifiers, collectSourcePackageUses, } from './verify-client-packages.ts' @@ -24,7 +24,7 @@ const WORKSPACE_MANIFEST_GLOBS = [ ] type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' -export type PackageDependencyRole = 'client-host' | 'configured-host' +export type PackageDependencyRole = 'client-only' | 'client-host' | 'configured-host' /** Manifest fields read and repaired by the package dependency policy. */ export interface PackageDependencyManifest { @@ -55,9 +55,22 @@ export interface PackageDependencyFacts { readonly workspaceNames: ReadonlySet readonly allSourceUses: ReadonlyMap readonly hostRuntimeSourceUses: ReadonlyMap + readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[] + readonly peerRequiredHostDependencies: ReadonlySet readonly clientInject: ReadonlySet } +/** One runtime export reached from a package's Host source closure. */ +export interface HostRuntimeExportUse { + readonly packageName: string + readonly specifier: string + readonly exportName: string + readonly sourcePath: string + readonly line: number + readonly column: number + readonly sourceLine: string +} + /** Complete policy input read from the repository. */ export interface PackageDependencyState { readonly facts: readonly PackageDependencyFacts[] @@ -154,13 +167,13 @@ export function discoverPackageDependencyScope( const selected: Array = [] for (const pkg of packages) { const clientDirectory = pkg.manifestPath.startsWith('packages/client/') - const clientHost = clientDirectory - || ((hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name)) + const clientHost = (hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name) + const clientOnly = clientDirectory && !clientHost const configuredHost = host.has(pkg.name) - if (configuredHost && clientHost) { + if (configuredHost && (clientHost || clientOnly)) { violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`) } - const role = clientHost ? 'client-host' : configuredHost ? 'configured-host' : undefined + const role = clientHost ? 'client-host' : clientOnly ? 'client-only' : configuredHost ? 'configured-host' : undefined if (role !== undefined) selected.push({ ...pkg, role }) } return { @@ -175,6 +188,95 @@ function addUse(target: Map, name: string, path: string): void target.set(name, paths) } +const NAMESPACE_RUNTIME_EXPORT = '*' +const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)' + +interface RuntimeSourceExportUse { + readonly specifier: string + readonly exportName: string + readonly line: number + readonly column: number + readonly sourceLine: string +} + +/** Collect exact runtime exports imported or re-exported by one source file. */ +export function collectRuntimeSourceExportUses(path: string, source: string): RuntimeSourceExportUse[] { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + const uses = new Map() + const sourceLines = source.split(/\r?\n/u) + const record = (specifier: string, exportName: string, locationNode: ts.Node): void => { + const key = `${specifier}\0${exportName}` + if (uses.has(key)) return + const position = sourceFile.getLineAndCharacterOfPosition(locationNode.getStart(sourceFile)) + uses.set(key, { + specifier, + exportName, + line: position.line + 1, + column: position.character + 1, + sourceLine: sourceLines[position.line]?.trim() ?? '', + }) + } + const add = ( + specifierNode: ts.Expression | undefined, + exportName: string, + locationNode: ts.Node = specifierNode ?? sourceFile, + ): void => { + if (specifierNode === undefined || !ts.isStringLiteralLike(specifierNode)) return + if (packageNameOf(specifierNode.text) === undefined) return + record(specifierNode.text, exportName, locationNode) + } + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause + if (clause === undefined) { + add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT) + } else if (clause.phaseModifier !== ts.SyntaxKind.TypeKeyword) { + if (clause.name !== undefined) add(node.moduleSpecifier, 'default', clause.name) + const bindings = clause.namedBindings + if (bindings !== undefined && ts.isNamespaceImport(bindings)) { + add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT, bindings.name) + } else if (bindings !== undefined && bindings.elements.length === 0) { + add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT) + } else if (bindings !== undefined) { + for (const element of bindings.elements) { + const imported = element.propertyName ?? element.name + if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported) + } + } + } + } else if (ts.isExportDeclaration(node) && !node.isTypeOnly) { + const clause = node.exportClause + if (clause === undefined || ts.isNamespaceExport(clause)) { + add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT) + } else if (clause.elements.length === 0) { + add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT) + } else { + for (const element of clause.elements) { + const imported = element.propertyName ?? element.name + if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported) + } + } + } else if (ts.isImportEqualsDeclaration(node) + && !node.isTypeOnly + && ts.isExternalModuleReference(node.moduleReference)) { + add(node.moduleReference.expression, NAMESPACE_RUNTIME_EXPORT, node.name) + } else if (ts.isCallExpression(node) + && (node.expression.kind === ts.SyntaxKind.ImportKeyword + || ts.isIdentifier(node.expression) && node.expression.text === 'require')) { + add(node.arguments[0], NAMESPACE_RUNTIME_EXPORT) + } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + record('react/jsx-runtime', NAMESPACE_RUNTIME_EXPORT, node) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return [...uses.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) + || left.exportName.localeCompare(right.exportName) + || left.line - right.line + || left.column - right.column) +} + function resolveLocal(importer: string, specifier: string): string | undefined { const raw = resolve(dirname(importer), specifier) const candidates = extname(raw) === '' @@ -183,8 +285,12 @@ function resolveLocal(importer: string, specifier: string): string | undefined { return candidates.find(candidate => existsSync(candidate)) } -function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map { - const uses = new Map() +function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): { + packageUses: Map + exportUses: HostRuntimeExportUse[] +} { + const packageUses = new Map() + const exportUses = new Map() const seen = new Set() const visit = (path: string): void => { const normalized = normalize(path) @@ -192,14 +298,29 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map + left.packageName.localeCompare(right.packageName) + || left.specifier.localeCompare(right.specifier) + || left.exportName.localeCompare(right.exportName) + || left.sourcePath.localeCompare(right.sourcePath) + || left.line - right.line + || left.column - right.column), + } } function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map { @@ -218,19 +339,82 @@ export function readPackageDependencyFacts( pkg: WorkspacePackageManifest, role: PackageDependencyRole, workspaceNames: ReadonlySet, + policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, ): PackageDependencyFacts { const inject = pkg.manifest.dsh?.client?.inject ?? [] + const hostRuntime = role === 'client-only' + ? { packageUses: new Map(), exportUses: [] } + : readHostRuntimeUses(root, pkg) return { manifestPath: pkg.manifestPath, role, manifest: pkg.manifest, workspaceNames, allSourceUses: readAllSourceUses(root, pkg), - hostRuntimeSourceUses: readHostRuntimeUses(root, pkg), + hostRuntimeSourceUses: hostRuntime.packageUses, + hostRuntimeExportUses: hostRuntime.exportUses, + peerRequiredHostDependencies: new Set(hostRuntime.exportUses + .filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) + .map(use => use.packageName)), clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)), } } +/** Validate reviewed Host export classifications against current source facts. */ +export function collectHostDependencyExportPolicyViolations( + facts: readonly PackageDependencyFacts[], + workspaceNames: ReadonlySet, + policy: Pick, +): string[] { + const violations: string[] = [] + const allRuntimeUses = facts.flatMap(fact => fact.hostRuntimeExportUses) + const classifications = [ + ['safeHostDependencyExports', policy.safeHostDependencyExports], + ['peerRequiredHostExports', policy.peerRequiredHostExports], + ] as const + for (const [field, entries] of classifications) { + for (const [specifier, exportNames] of Object.entries(entries)) { + const provider = packageNameOf(specifier) + if (provider === undefined || !workspaceNames.has(provider)) { + violations.push(`${field} specifier ${specifier} is not a workspace package`) + } + if (exportNames.length === 0) { + violations.push(`${field} lists no exports for ${specifier}`) + } + for (const exportName of duplicates(exportNames)) { + violations.push(`${field} lists ${specifier} export ${exportName} more than once`) + } + for (const exportName of exportNames) { + if (exportName === '' || exportName === NAMESPACE_RUNTIME_EXPORT || exportName === SIDE_EFFECT_RUNTIME_EXPORT) { + violations.push(`${field} cannot classify unbounded ${specifier} export ${exportName}`) + continue + } + if (!allRuntimeUses.some(use => use.specifier === specifier && use.exportName === exportName)) { + violations.push(`${field} lists unused ${specifier} export ${exportName}`) + } + if (field === 'safeHostDependencyExports' + && policy.peerRequiredHostExports[specifier]?.includes(exportName) === true) { + violations.push(`${specifier} export ${exportName} appears in both Host export classifications`) + } + } + } + } + + for (const fact of facts) { + for (const use of fact.hostRuntimeExportUses) { + if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue + if (!workspaceNames.has(use.packageName) && fact.manifest.peerDependencies?.[use.packageName] === undefined) continue + if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue + if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue + violations.push( + `${use.sourcePath}:${String(use.line)}:${String(use.column)}: ` + + `${use.specifier}#${use.exportName} is not classified as safe or peer-required — ${use.sourceLine}`, + ) + } + } + return violations.sort() +} + /** Read every package covered by the current dependency policy. */ export function readPackageDependencyState( root: string, @@ -239,10 +423,15 @@ export function readPackageDependencyState( const packages = readWorkspacePackageManifests(root) const workspaceNames = new Set(packages.all.map(pkg => pkg.name)) const discovered = discoverPackageDependencyScope(packages.release, policy) + const facts = discovered.selected.map(pkg => + readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy)) return { - facts: discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames)), + facts, packages: packages.release, - policyViolations: discovered.violations, + policyViolations: [ + ...discovered.violations, + ...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy), + ].sort(), workspaceNames, } } @@ -255,9 +444,11 @@ export function expectedPackageDependencies( const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => { if (name === facts.manifest.name || name === CORDIS) return const current = expected.get(name) - const section = current?.section === 'dependencies' || sectionName === 'dependencies' - ? 'dependencies' - : 'devDependencies' + const section = current?.section === 'peer-dev' || sectionName === 'peer-dev' + ? 'peer-dev' + : current?.section === 'dependencies' || sectionName === 'dependencies' + ? 'dependencies' + : 'devDependencies' expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) }) } @@ -274,7 +465,8 @@ export function expectedPackageDependencies( } for (const [name, paths] of facts.hostRuntimeSourceUses) { if (!facts.workspaceNames.has(name) && facts.manifest.peerDependencies?.[name] === undefined) continue - for (const path of paths) add(name, 'dependencies', path) + const expectedSection = facts.peerRequiredHostDependencies.has(name) ? 'peer-dev' : 'dependencies' + for (const path of paths) add(name, expectedSection, path) } return new Map([...expected].map(([name, rule]) => [name, { section: rule.section, @@ -282,23 +474,46 @@ export function expectedPackageDependencies( }])) } -/** Format the managed Host runtime edges that remain ordinary dependencies. */ +interface ManagedRuntimeEdge { + readonly consumer: string + readonly dependency: string + readonly exports: readonly string[] +} + +function managedRuntimeEdges( + state: PackageDependencyState, + expectedSection: 'dependencies' | 'peer-dev', +): ManagedRuntimeEdge[] { + return state.facts.flatMap(facts => [...expectedPackageDependencies(facts)] + .filter(([name, rule]) => name !== CORDIS && rule.section === expectedSection) + .map(([dependency]) => ({ + consumer: facts.manifest.name ?? facts.manifestPath, + dependency, + exports: [...new Set(facts.hostRuntimeExportUses + .filter(use => use.packageName === dependency) + .map(use => `${use.specifier}#${use.exportName}`))].sort(), + }))) + .sort((left, right) => + left.consumer.localeCompare(right.consumer) || left.dependency.localeCompare(right.dependency)) +} + +/** Format Host runtime edges whose reviewed exports permit ordinary dependencies. */ export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] { - const rows = state.facts.flatMap((facts) => { - const dependencies = [...expectedPackageDependencies(facts)] - .filter(([, rule]) => rule.section === 'dependencies') - .map(([name]) => name) - .sort() - if (dependencies.length === 0) return [] - return [{ - name: facts.manifest.name ?? facts.manifestPath, - dependencies, - }] - }).sort((left, right) => left.name.localeCompare(right.name)) - const edges = rows.reduce((total, row) => total + row.dependencies.length, 0) + const rows = managedRuntimeEdges(state, 'dependencies') + const packages = new Set(rows.map(row => row.consumer)).size return [ - `${GATE}: ${String(edges)} managed Host runtime dependency edge(s) remain in dependencies across ${String(rows.length)} package(s):`, - ...rows.map(row => ` ${row.name}: ${row.dependencies.join(', ')}`), + `${GATE}: ${String(rows.length)} managed Host runtime edge(s) remain in dependencies across ${String(packages)} package(s):`, + ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`), + ] +} + +/** Format Host runtime edges retained as peers by their imported export classification. */ +export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] { + const rows = managedRuntimeEdges(state, 'peer-dev') + const packages = new Set(rows.map(row => row.consumer)).size + return [ + `${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`, + ...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`), ] } @@ -323,6 +538,7 @@ function describeSections(sections: readonly DependencySection[]): string { /** Return all manifest and policy violations in stable order. */ export function collectPackageDependencyViolations(state: PackageDependencyState): string[] { const violations = [...state.policyViolations] + if (violations.length > 0) return [...new Set(violations)].sort() for (const facts of state.facts) { for (const [name, rule] of expectedPackageDependencies(facts)) { const actual = declaredSections(facts.manifest, name) @@ -454,9 +670,13 @@ function main(): void { let state = readPackageDependencyState(root) const fix = process.argv.includes('--fix') if (fix) { - const changed = fixPackageDependencies(root, state) - console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`) - state = readPackageDependencyState(root) + if (state.policyViolations.length > 0) { + console.error(`${GATE}: --fix skipped because dependency policy review failed.`) + } else { + const changed = fixPackageDependencies(root, state) + console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`) + state = readPackageDependencyState(root) + } } const violations = collectPackageDependencyViolations(state) if (violations.length > 0) { @@ -468,11 +688,13 @@ function main(): void { const roles = Object.groupBy(state.facts, fact => fact.role) console.log( `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy` - + ` (${String(roles['client-host']?.length ?? 0)} Client/Host,` + + ` (${String(roles['client-only']?.length ?? 0)} Client-only,` + + ` ${String(roles['client-host']?.length ?? 0)} Client/Host,` + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`, ) if (fix) { for (const line of formatManagedRuntimeDependencies(state)) console.log(line) + for (const line of formatPeerRequiredRuntimeDependencies(state)) console.log(line) } } From 943a544899f8870b21c91d278cfe00bc9f9ce19f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:11:24 +0800 Subject: [PATCH 075/429] feat: classify Host dependency exports --- ...08-26-published-dependency-faces.i18n.yaml | 4 +-- .../2026-08-26-published-dependency-faces.md | 35 +++++++++++++++++-- ...026-08-26-published-dependency-faces.zh.md | 35 +++++++++++++++++-- scripts/package-dependency-policy.ts | 32 +++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index 433b12fa8c..7f3153a5b2 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 04b02afe372b2f3d90729f17e837b40ff1e0e6fa -2026-08-26-published-dependency-faces.zh.md: 468fb443e2e94bf23f8c61201f43c2a82f7f509b +2026-08-26-published-dependency-faces.md: a4c745e8bc811027791317b334d285274f24dd20 +2026-08-26-published-dependency-faces.zh.md: 73b8dd28f40a63ea2a13544be535be7832138ba2 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 04b02afe37..a4c745e8bc 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -30,11 +30,40 @@ Workspace imports used by the Client bundle, type-only imports, module augmentat The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata. +### Maintainer workflow + +Run the verifier without `--fix` for a read-only check of package selection, export classifications, dependency sections, workspace ranges, and peer metadata. An unclassified runtime import reports one clickable `path:line:column` diagnostic per imported export. + +```sh +pnpm run verify-package-dependencies +``` + +Classify each new Host runtime export in [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) before generating manifests. `safeHostDependencyExports` permits an ordinary dependency; `peerRequiredHostExports` keeps the whole provider package edge in matching peer and development sections. An export may appear in exactly one table. After refactoring a peer-required export so duplicate package copies are safe, move that exact specifier and export to the safe table; an edge becomes an ordinary dependency only after none of its imported exports remain peer-required. + +Generate managed manifest sections, refresh the lockfile separately, and review both results. `--fix` writes nothing while a policy violation exists; after success it prints the ordinary-dependency and peer-required edge lists, but does not update the lockfile. + +```sh +pnpm run verify-package-dependencies -- --fix +pnpm install --lockfile-only +git diff -- packages pnpm-lock.yaml +``` + +Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` bounds each run, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold. + +```sh +pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000 +pnpm run benchmark:npm-resolution -- --ref=origin/master --runs=5 --timeout-ms=300000 +``` + +Rank the next Host package by applying the current policy in memory, measuring a baseline, trying each reachable unconfigured package, and serially retesting the fastest coarse candidates. Positive `gainSeconds` is `baseline median - candidate median`; `--candidates` limits the roster, `--jobs` controls coarse concurrency, and neither phase writes manifests. A selected candidate still requires export classification before it joins `hostPackages`. + +```sh +pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists=5 --jobs=8 --timeout-ms=120000 +``` + ### Performance verification -[`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) applies the current policy to an in-memory local registry, measures the current CLI graph, and tries every reachable unconfigured Host package one at a time. Concurrent runs provide a coarse shortlist; finalists run serially because npm's peer-placement search can take different paths when metadata request completion order changes. - -The benchmark is manual rather than a CI gate. It performs metadata-only installs in fresh consumers, so its relative results identify peer relays without measuring registry latency or archive downloads. +[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) and [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) remain manual rather than CI gates because resolver time varies with machine load and metadata completion order. Their fresh-consumer, metadata-only runs isolate npm's dependency-tree calculation from registry latency and archive downloads, so relative results identify peer relays without creating a release-time performance promise. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index 468fb443e2..73b8dd28f4 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -30,11 +30,40 @@ Client bundle 使用的 workspace import、纯类型 import、模块扩充、`ds 验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 +### 维护流程 + +不带 `--fix` 运行验证器,会以只读方式检查选包范围、导出分类、依赖区段、workspace range 与 peer metadata。未分类的运行期 import 会按每个导出分别报告可点击的 `path:line:column` 诊断。 + +```sh +pnpm run verify-package-dependencies +``` + +生成 manifest 前,在 [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 中分类每个新增 Host 运行期导出。`safeHostDependencyExports` 允许普通 dependency;`peerRequiredHostExports` 让整个提供包依赖边保留在范围一致的 peer 与开发区段。一个导出只能出现在一个表中。把 peer-required 导出重构到重复安装安全后,将该精确 specifier 与导出移入 safe 表;只有当一条依赖边的所有 import 都不再使用 peer-required 导出时,它才会成为普通 dependency。 + +生成受管 manifest 区段后,单独刷新 lockfile,并审查两部分结果。存在策略违规时,`--fix` 不写任何文件;成功后,它会分别打印普通 dependency 与 peer-required 依赖边,但不会更新 lockfile。 + +```sh +pnpm run verify-package-dependencies -- --fix +pnpm install --lockfile-only +git diff -- packages pnpm-lock.yaml +``` + +通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 限制单轮耗时,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。 + +```sh +pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000 +pnpm run benchmark:npm-resolution -- --ref=origin/master --runs=5 --timeout-ms=300000 +``` + +计算下一项 Host 包时,命令会在内存中应用当前策略、测量 baseline、逐个尝试可达且未配置的包,并串行复测粗筛中最快的候选。正数 `gainSeconds` 等于 `baseline median - candidate median`;`--candidates` 限定名册,`--jobs` 控制粗筛并发度,两个阶段都不写 manifest。选中的候选仍需先完成导出分类,才能加入 `hostPackages`。 + +```sh +pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists=5 --jobs=8 --timeout-ms=120000 +``` + ### 性能验证 -[`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 把当前策略应用到内存中的本地 registry,测量当前 CLI 依赖图,并逐个尝试每个可达且未配置的 Host 包。并发运行用于得到粗筛名单;由于 metadata 请求的完成顺序会让 npm 的 peer 放置搜索走不同路径,最终候选会串行复测。 - -Benchmark 是手动诊断工具而非 CI 门禁。它在全新 consumer 中执行仅 metadata 的安装,因此相对结果可以定位 peer 中继,但不测量 registry 延迟或包归档下载。 +[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) 与 [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 保持为手动工具而非 CI 门禁,因为 resolver 耗时会随机器负载和 metadata 完成顺序变化。它们通过全新 consumer 和仅 metadata 的运行,把 npm 依赖树计算与 registry 延迟、包归档下载分离,因此相对结果可以定位 peer 中继,但不构成发布时性能承诺。 ## 考虑过的替代方案 diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 1a172daafc..4160acd422 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -6,6 +6,7 @@ const CLIENT_FACE_INCLUDE: readonly string[] = [] /** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ const CLIENT_FACE_EXCLUDE: readonly string[] = [ '@deepseek-ai/dsh-api-session-controller', + '@deepseek-ai/dsh-api-workspace-controller', ] /** Host-only packages whose peer relays are deliberately flattened. */ @@ -14,11 +15,40 @@ const HOST_DEPENDENCY_PACKAGES: readonly string[] = [ '@deepseek-ai/dsh-session', ] +/** + * Runtime exports whose values remain valid when npm installs another package copy. + */ +const SAFE_HOST_DEPENDENCY_EXPORTS = { + '@deepseek-ai/dsh-api-session-controller/remote-events': ['SESSION_CONTROLLER_REMOTE_EVENTS'], + '@deepseek-ai/dsh-credentials': ['credentialKey'], + '@deepseek-ai/dsh-host-apiproxy': ['toFetchHandler'], + '@deepseek-ai/dsh-host-apiproxy/api': ['RpcId', 'clientRequestSchema'], + '@deepseek-ai/dsh-llm': ['MessageId', 'callConfigEquals', 'deepFreeze', 'freezeMessage'], + '@deepseek-ai/dsh-llm/brand': ['ToolCallId'], + '@deepseek-ai/dsh-session': ['isJsonValue'], + '@deepseek-ai/dsh-settings': ['settingsNamespace'], + '@deepseek-ai/dsh-system-prompt': ['FIRST_PARTY_SECTION_ORDER'], + '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'], + '@deepseek-ai/dsh-util-crypto': ['randomUUID'], + '@deepseek-ai/schemastery': ['default'], +} as const satisfies HostDependencyExports + +/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ +const PEER_REQUIRED_HOST_EXPORTS = { + '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], + '@deepseek-ai/dsh-typert-protocol': ['TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods'], +} as const satisfies HostDependencyExports + +/** Exact import specifier to reviewed runtime exports. */ +type HostDependencyExports = Readonly> + /** Complete configurable input to package dependency classification. */ export interface PackageDependencyPolicy { readonly clientFaceInclude: readonly string[] readonly clientFaceExclude: readonly string[] readonly hostPackages: readonly string[] + readonly safeHostDependencyExports: HostDependencyExports + readonly peerRequiredHostExports: HostDependencyExports } /** Repository dependency policy consumed by verification and benchmarking. */ @@ -26,6 +56,8 @@ export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = { clientFaceInclude: CLIENT_FACE_INCLUDE, clientFaceExclude: CLIENT_FACE_EXCLUDE, hostPackages: HOST_DEPENDENCY_PACKAGES, + safeHostDependencyExports: SAFE_HOST_DEPENDENCY_EXPORTS, + peerRequiredHostExports: PEER_REQUIRED_HOST_EXPORTS, } function isRecord(value: unknown): value is Record { From 91b5a0198061e2fb4f076a2370d4aa96130a683b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:52:19 +0800 Subject: [PATCH 076/429] fix: complete dependency policy generation --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 7 +- ...026-08-26-published-dependency-faces.zh.md | 7 +- packages/client/AGENTS.md | 2 +- scripts/gen-module-graph.ts | 154 ++++++++++++------ scripts/package-dependency-policy.ts | 16 ++ scripts/package-graph.spec.ts | 21 +++ scripts/verify-package-dependencies.spec.ts | 14 +- scripts/verify-package-dependencies.ts | 24 +++ 9 files changed, 191 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index 7f3153a5b2..689c45dea9 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: a4c745e8bc811027791317b334d285274f24dd20 -2026-08-26-published-dependency-faces.zh.md: 73b8dd28f40a63ea2a13544be535be7832138ba2 +2026-08-26-published-dependency-faces.md: 6174e2a46482362c0ff54b5dec261440ce4f6131 +2026-08-26-published-dependency-faces.zh.md: 090031e9cde07d75aae79a87207d879fe220257d diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index a4c745e8bc..6174e2a464 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -28,6 +28,8 @@ A workspace package reached by a runtime value import from the Host entry closur Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`. +Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`. + The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata. ### Maintainer workflow @@ -40,12 +42,11 @@ pnpm run verify-package-dependencies Classify each new Host runtime export in [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) before generating manifests. `safeHostDependencyExports` permits an ordinary dependency; `peerRequiredHostExports` keeps the whole provider package edge in matching peer and development sections. An export may appear in exactly one table. After refactoring a peer-required export so duplicate package copies are safe, move that exact specifier and export to the safe table; an edge becomes an ordinary dependency only after none of its imported exports remain peer-required. -Generate managed manifest sections, refresh the lockfile separately, and review both results. `--fix` writes nothing while a policy violation exists; after success it prints the ordinary-dependency and peer-required edge lists, but does not update the lockfile. +Generate the managed manifests and every directly derived artifact with one command. `--fix` writes nothing while a policy violation exists; after success it refreshes `pnpm-lock.yaml`, regenerates both module-graph languages and their pairing record, and prints the ordinary-dependency and peer-required edge lists. ```sh pnpm run verify-package-dependencies -- --fix -pnpm install --lockfile-only -git diff -- packages pnpm-lock.yaml +git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml ``` Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` bounds each run, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index 73b8dd28f4..090031e9cd 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -28,6 +28,8 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只 Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。 +部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。 + 验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 ### 维护流程 @@ -40,12 +42,11 @@ pnpm run verify-package-dependencies 生成 manifest 前,在 [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 中分类每个新增 Host 运行期导出。`safeHostDependencyExports` 允许普通 dependency;`peerRequiredHostExports` 让整个提供包依赖边保留在范围一致的 peer 与开发区段。一个导出只能出现在一个表中。把 peer-required 导出重构到重复安装安全后,将该精确 specifier 与导出移入 safe 表;只有当一条依赖边的所有 import 都不再使用 peer-required 导出时,它才会成为普通 dependency。 -生成受管 manifest 区段后,单独刷新 lockfile,并审查两部分结果。存在策略违规时,`--fix` 不写任何文件;成功后,它会分别打印普通 dependency 与 peer-required 依赖边,但不会更新 lockfile。 +用一条命令生成受管 manifest 和所有直接派生产物。存在策略违规时,`--fix` 不写任何文件;成功后,它会刷新 `pnpm-lock.yaml`、重新生成中英文 module graph 及其配对记录,并打印普通 dependency 与 peer-required 依赖边。 ```sh pnpm run verify-package-dependencies -- --fix -pnpm install --lockfile-only -git diff -- packages pnpm-lock.yaml +git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml ``` 通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 限制单轮耗时,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 6ef0c5e42c..1b33de5039 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -60,7 +60,7 @@ Npm sections describe installation and development relationships; each build fac 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract. 2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy. -3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. +3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. Configuration-only entries that Knip cannot infer from imports are listed in the dependency policy and projected into `knip.json` by `--fix`. 4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests. 5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 0d1b16588f..d095c1afed 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -1,21 +1,21 @@ -/** - * Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical - * runtime edges. The deterministic output groups packages by directory and - * renders both Mermaid and a dependency table; `--check` verifies freshness. - */ +/** Generate the paired shared-instance package graph from workspace peer dependencies. */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' -import { readFileSync, writeFileSync } from 'node:fs' import { collectPackageGraph, escapeMermaidLabel as escLabel, graphNodeId as nodeId, type PackageGraphNode, } from './package-graph.ts' +import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts' +import { renderTranslationPairingRecord, translationPairPaths } from './translation-pairing-record.ts' const root = resolve(import.meta.dirname, '..') -const OUT = 'docs/module-graph.md' +const SOURCE = 'docs/module-graph.md' +const PATHS = translationPairPaths(SOURCE) type Pkg = PackageGraphNode +type Locale = 'en' | 'zh' const GROUP_ORDER = [ 'util', @@ -46,42 +46,55 @@ function packageLink(pkg: Pkg): string { return `[\`${pkg.short}\`](../${pkg.rel})` } -/** Render the full docs/module-graph.md content (pure, deterministic). */ -function render(pkgs: Pkg[]): string { +/** + * Render one locale of the complete deterministic package graph. + * @param pkgs - Dependency-first package nodes. + * @param locale - Output document language. + * @returns Complete generated Markdown. + */ +export function renderModuleGraph(pkgs: readonly Pkg[], locale: Locale): string { const edges: string[] = [] - for (const p of pkgs) { - for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`) + for (const pkg of pkgs) { + for (const dependency of pkg.deps) edges.push(` ${nodeId('pkg', pkg.short)} --> ${nodeId('pkg', dependency)}`) } const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) - const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => { - const ia = GROUP_ORDER.indexOf(a) - const ib = GROUP_ORDER.indexOf(b) - const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia - const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib - return na - nb || a.localeCompare(b) + const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((left, right) => { + const leftIndex = GROUP_ORDER.indexOf(left) + const rightIndex = GROUP_ORDER.indexOf(right) + const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex + const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex + return normalizedLeft - normalizedRight || left.localeCompare(right) }) const groupBlocks: string[] = [] for (const group of groups) { groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`) - for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) { + for (const pkg of pkgs.filter(candidate => candidate.group === group) + .sort((left, right) => left.short.localeCompare(right.short))) { groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`) } groupBlocks.push(' end') } - const rows = pkgs.map((p) => { - const deps = p.deps.length ? p.deps.map((d) => { - const dep = byShort.get(d) - return dep ? packageLink(dep) : `\`${d}\`` - }).join(', ') : '—' - return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |` + const rows = pkgs.map((pkg) => { + const dependencies = pkg.deps.length > 0 + ? pkg.deps.map((dependency) => { + const target = byShort.get(dependency) + return target ? packageLink(target) : `\`${dependency}\`` + }).join(', ') + : '—' + return `| ${packageLink(pkg)} | \`${pkg.group}\` | ${dependencies} |` }) + const chinese = locale === 'zh' return [ - '', + chinese + ? '' + : '', '', - '# Module dependency graph', + chinese ? '# 共享实例依赖关系图' : '# Shared-instance dependency graph', '', - 'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', + ...(chinese ? ['[English](module-graph.md) | 中文', ''] : []), + chinese + ? '`@deepseek-ai/dsh-*` harness 包之间的 peer 依赖关系。peer 表示消费端需要提供共享实例,不包括普通运行时 dependency 或仅开发期关系。该图按 `packages//` 层级分组;边 `a --> b` 表示包 `a` peer 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。' + : 'Peer dependencies among the `@deepseek-ai/dsh-*` harness packages. A peer means the consumer requires a shared instance; ordinary runtime dependencies and development-only relationships are not shown. The graph is grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` has package `b` as a peer. Names omit the `@deepseek-ai/dsh-` prefix.', '', '```mermaid', 'flowchart TD', @@ -89,31 +102,76 @@ function render(pkgs: Pkg[]): string { ...edges, '```', '', - '| Package | Group | Depends on |', + chinese ? '| 包 | 分组 | Peer 依赖 |' : '| Package | Group | Peer dependencies |', '| --- | --- | --- |', ...rows, '', ].join('\n') } -const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph')) - -if (process.argv.includes('--check')) { - let committed: string | null = null - try { - committed = readFileSync(resolve(root, OUT), 'utf8') - } catch { - // A missing artifact is the expected read failure. Any read failure has the - // same remedy here—regenerate—so it is reported as stale below. - committed = null - } - if (committed === content) { - console.log(`gen-module-graph: ${OUT} is up to date.`) - process.exit(0) - } - console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`) - process.exit(1) +/** + * Compute both localized graph documents from the current workspace manifests. + * @param scanRoot - Repository root containing packages and documentation. + * @returns Repository-relative output paths and exact generated content. + */ +export function computeModuleGraphOutputs(scanRoot: string = root): ReadonlyMap { + const packages = collectPackageGraph(scanRoot, GROUP_ORDER, 'gen-module-graph') + return new Map([ + [PATHS.source, renderModuleGraph(packages, 'en')], + [PATHS.zh, renderModuleGraph(packages, 'zh')], + ]) } -writeFileSync(resolve(root, OUT), content) -console.log(`gen-module-graph: wrote ${OUT}.`) +/** + * Write both graph documents and their recovery record. + * @param scanRoot - Repository root containing packages and documentation. + * @returns Repository-relative paths whose content changed. + */ +export function writeModuleGraph(scanRoot: string = root): string[] { + const outputs = computeModuleGraphOutputs(scanRoot) + const changed: string[] = [] + for (const [path, content] of outputs) { + const destination = resolve(scanRoot, path) + if (existsSync(destination) && readFileSync(destination, 'utf8') === content) continue + writeFileSync(destination, content) + changed.push(path) + } + const source = Buffer.from(outputs.get(PATHS.source) ?? '') + const zh = Buffer.from(outputs.get(PATHS.zh) ?? '') + const record = renderTranslationPairingRecord(PATHS, { + sourceHash: storeGitBlob(scanRoot, source), + zhHash: storeGitBlob(scanRoot, zh), + }) + const recordPath = resolve(scanRoot, PATHS.meta) + if (!existsSync(recordPath) || readFileSync(recordPath, 'utf8') !== record) { + writeFileSync(recordPath, record) + changed.push(PATHS.meta) + } + return changed.sort() +} + +/** CLI entry: regenerate by default, or verify all paired outputs with `--check`. @returns Nothing. */ +export function main(): void { + const outputs = computeModuleGraphOutputs(root) + const record = renderTranslationPairingRecord(PATHS, { + sourceHash: gitBlobHash(Buffer.from(outputs.get(PATHS.source) ?? '')), + zhHash: gitBlobHash(Buffer.from(outputs.get(PATHS.zh) ?? '')), + }) + const expected = new Map([...outputs, [PATHS.meta, record]]) + if (process.argv.includes('--check')) { + const stale = [...expected].filter(([path, content]) => ( + !existsSync(resolve(root, path)) || readFileSync(resolve(root, path), 'utf8') !== content + )).map(([path]) => path) + if (stale.length === 0) { + console.log(`gen-module-graph: ${expected.size} artifact(s) are up to date.`) + return + } + console.error(`gen-module-graph: stale — ${stale.join(', ')}. Run \`pnpm run gen-module-graph\` and commit the result.`) + process.exitCode = 1 + return + } + const changed = writeModuleGraph(root) + console.log(`gen-module-graph: ${expected.size} artifact(s) computed, ${String(changed.length)} written.`) +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) main() diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 4160acd422..2d8f7eb56c 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -15,6 +15,20 @@ const HOST_DEPENDENCY_PACKAGES: readonly string[] = [ '@deepseek-ai/dsh-session', ] +/** Development-only package relationships not represented by source imports. */ +const CONFIGURATION_ONLY_DEV_DEPENDENCIES = { + '@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'], + '@deepseek-ai/dsh-client-ui-conversation': [ + '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-client-ui-workspace', + ], + '@deepseek-ai/dsh-client-ui-model-selection': ['@deepseek-ai/dsh-client-ui-input-trigger'], + '@deepseek-ai/dsh-client-ui-sidebar': ['@deepseek-ai/dsh-client-ui-workspace'], + '@deepseek-ai/dsh-client-ui-subagent': ['@deepseek-ai/dsh-client-ui-input-trigger'], + '@deepseek-ai/dsh-client-ui-theme': ['@deepseek-ai/dsh-api-remotes'], + '@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'], +} as const satisfies Readonly> + /** * Runtime exports whose values remain valid when npm installs another package copy. */ @@ -47,6 +61,7 @@ export interface PackageDependencyPolicy { readonly clientFaceInclude: readonly string[] readonly clientFaceExclude: readonly string[] readonly hostPackages: readonly string[] + readonly configurationOnlyDevDependencies: Readonly> readonly safeHostDependencyExports: HostDependencyExports readonly peerRequiredHostExports: HostDependencyExports } @@ -56,6 +71,7 @@ export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = { clientFaceInclude: CLIENT_FACE_INCLUDE, clientFaceExclude: CLIENT_FACE_EXCLUDE, hostPackages: HOST_DEPENDENCY_PACKAGES, + configurationOnlyDevDependencies: CONFIGURATION_ONLY_DEV_DEPENDENCIES, safeHostDependencyExports: SAFE_HOST_DEPENDENCY_EXPORTS, peerRequiredHostExports: PEER_REQUIRED_HOST_EXPORTS, } diff --git a/scripts/package-graph.spec.ts b/scripts/package-graph.spec.ts index fecbccedfe..99b3c3b9f1 100644 --- a/scripts/package-graph.spec.ts +++ b/scripts/package-graph.spec.ts @@ -2,6 +2,7 @@ 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 { renderModuleGraph } from './gen-module-graph.ts' import { collectPackageGraph } from './package-graph.ts' const roots: string[] = [] @@ -49,3 +50,23 @@ describe('collectPackageGraph', () => { .toThrow('fixture: @deepseek-ai/dsh-consumer references missing in-repo peer @deepseek-ai/dsh-missing') }) }) + +describe('renderModuleGraph', () => { + it('renders the same peer edge in both generated languages', () => { + const packages = [ + { short: 'provider', name: '@deepseek-ai/dsh-provider', group: 'core', rel: 'packages/core/provider', deps: [] }, + { short: 'consumer', name: '@deepseek-ai/dsh-consumer', group: 'core', rel: 'packages/core/consumer', deps: ['provider'] }, + ] + + const english = renderModuleGraph(packages, 'en') + const chinese = renderModuleGraph(packages, 'zh') + + expect(english).toContain('# Shared-instance dependency graph') + expect(chinese).toContain('# 共享实例依赖关系图') + expect(chinese).toContain('[English](module-graph.md) | 中文') + for (const output of [english, chinese]) { + expect(output).toContain('pkg_consumer --> pkg_provider') + expect(output).toContain('| [`consumer`](../packages/core/consumer) | `core` | [`provider`](../packages/core/provider) |') + } + }) +}) diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 23c1bbca69..ad4752f9a7 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -46,6 +46,7 @@ function policy(fields: Partial = {}): PackageDependenc clientFaceInclude: [], clientFaceExclude: [], hostPackages: [], + configurationOnlyDevDependencies: {}, safeHostDependencyExports: {}, peerRequiredHostExports: {}, ...fields, @@ -95,6 +96,18 @@ describe('package dependency scope', () => { '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-session', ]) + expect(PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies).toEqual({ + '@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'], + '@deepseek-ai/dsh-client-ui-conversation': [ + '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-client-ui-workspace', + ], + '@deepseek-ai/dsh-client-ui-model-selection': ['@deepseek-ai/dsh-client-ui-input-trigger'], + '@deepseek-ai/dsh-client-ui-sidebar': ['@deepseek-ai/dsh-client-ui-workspace'], + '@deepseek-ai/dsh-client-ui-subagent': ['@deepseek-ai/dsh-client-ui-input-trigger'], + '@deepseek-ai/dsh-client-ui-theme': ['@deepseek-ai/dsh-api-remotes'], + '@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'], + }) expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default']) expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([ 'carrierKeyOf', 'scopeOf', 'scopeTarget', @@ -219,7 +232,6 @@ describe('face-aware source classification', () => { mkdirSync(dirname(join(root, path)), { recursive: true }) writeFileSync(join(root, path), source) } - const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([ CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected', ])) diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index f8ac1cf3dd..3782d61309 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -1,8 +1,10 @@ /** Verify and repair npm dependency sections from published Client and Host faces. */ +import { spawnSync } from 'node:child_process' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path' import ts from 'typescript' +import { writeModuleGraph } from './gen-module-graph.ts' import { hasClientDeclaration, PACKAGE_DEPENDENCY_POLICY, @@ -439,6 +441,7 @@ export function readPackageDependencyState( /** Derive the required npm section for each relationship owned by the policy. */ export function expectedPackageDependencies( facts: PackageDependencyFacts, + policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, ): ReadonlyMap { const expected = new Map }>() const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => { @@ -460,6 +463,12 @@ export function expectedPackageDependencies( for (const name of facts.clientInject) { if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject') } + for (const name of PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) { + if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship') + } + for (const name of policy.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) { + if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship') + } for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) { if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer') } @@ -665,6 +674,16 @@ export function fixPackageDependencies(root: string, state: PackageDependencySta return changed.sort() } +function refreshPnpmLockfile(root: string): void { + const result = spawnSync( + 'pnpm', + ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'], + { cwd: root, shell: process.platform === 'win32', stdio: 'inherit' }, + ) + if (result.error !== undefined) throw new Error(`could not refresh pnpm-lock.yaml: ${result.error.message}`) + if (result.status !== 0) throw new Error(`pnpm lockfile refresh exited with status ${String(result.status)}`) +} + function main(): void { const root = resolve(import.meta.dirname, '..') let state = readPackageDependencyState(root) @@ -675,6 +694,11 @@ function main(): void { } else { const changed = fixPackageDependencies(root, state) console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`) + refreshPnpmLockfile(root) + const graphChanges = writeModuleGraph(root) + console.log( + `${GATE}: refreshed pnpm-lock.yaml and wrote ${String(graphChanges.length)} module-graph artifact(s).`, + ) state = readPackageDependencyState(root) } } From b46d36d3bf4ba352df62cc86b6329c038c4209cf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:23:01 +0800 Subject: [PATCH 077/429] test(release): verify dual-version npm layout --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 10 +- ...026-08-26-published-dependency-faces.zh.md | 10 +- .github/workflows/release.yml | 46 +++- package.json | 1 + scripts/benchmark-npm-resolution.spec.ts | 23 ++ scripts/benchmark-npm-resolution.ts | 70 +++++- scripts/ci-workflow.spec.ts | 16 +- scripts/verify-npm-install-layout.spec.ts | 102 ++++++++ scripts/verify-npm-install-layout.ts | 217 ++++++++++++++++++ 10 files changed, 482 insertions(+), 17 deletions(-) create mode 100644 scripts/verify-npm-install-layout.spec.ts create mode 100644 scripts/verify-npm-install-layout.ts diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index 689c45dea9..e09edf4f86 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 6174e2a46482362c0ff54b5dec261440ce4f6131 -2026-08-26-published-dependency-faces.zh.md: 090031e9cde07d75aae79a87207d879fe220257d +2026-08-26-published-dependency-faces.md: aa7714e5c83aaf1c68ef300bdb10006e534bf88d +2026-08-26-published-dependency-faces.zh.md: e0ba33ff25651d1adf378af87fd6065ed9e54d03 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 6174e2a464..aa7714e5c8 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -56,6 +56,12 @@ pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000 pnpm run benchmark:npm-resolution -- --ref=origin/master --runs=5 --timeout-ms=300000 ``` +Verify package placement through two incompatible synthetic DSH releases. The verifier copies every current DSH manifest into `0.1.0` and `0.2.0`, asks npm for a package lock only, and rejects cross-release DSH resolution, unexpected DSH locations, unequal release inventories, multiple Cordis installations, and package archive requests. The local index contains only installed current-platform metadata, so npm-accepted probes for unavailable optional packages are reported without failing the check. + +```sh +pnpm run verify-npm-install-layout +``` + Rank the next Host package by applying the current policy in memory, measuring a baseline, trying each reachable unconfigured package, and serially retesting the fastest coarse candidates. Positive `gainSeconds` is `baseline median - candidate median`; `--candidates` limits the roster, `--jobs` controls coarse concurrency, and neither phase writes manifests. A selected candidate still requires export classification before it joins `hostPackages`. ```sh @@ -64,7 +70,7 @@ pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists ### Performance verification -[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) and [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) remain manual rather than CI gates because resolver time varies with machine load and metadata completion order. Their fresh-consumer, metadata-only runs isolate npm's dependency-tree calculation from registry latency and archive downloads, so relative results identify peer relays without creating a release-time performance promise. +[`verify-npm-install-layout`](../../../../scripts/verify-npm-install-layout.ts) is a deterministic package-path and version check in the `Release (dsh)` workflow on every pull request and master push; it does not enforce resolver duration. [`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) and [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) remain manual because resolver time varies with machine load and metadata completion order. Their fresh-consumer, metadata-only runs isolate npm's dependency-tree calculation from registry latency and archive downloads, so relative results identify peer relays without creating a release-time performance promise. ## Alternatives considered @@ -84,4 +90,4 @@ The published dependency graph follows artifact ownership instead of source-dire Moving a public type-only relationship to `devDependencies` means a standalone TypeScript consumer must install the referenced type package when it consumes that declaration. The shipped profiles install the complete supported package family; supporting independently assembled TypeScript consumers would require a different policy. -The explicit overrides, Host list, and export classifications are reviewable decisions. Class constructors used by `instanceof`, symbols, and accessors for module-private registries require peers when their identity or state crosses package boundaries; being a value import alone does not make an export duplicate-safe. Changing a classification changes the installed graph and requires the focused verifier tests plus a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise. +The explicit overrides, Host list, and export classifications are reviewable decisions. Class constructors used by `instanceof`, symbols, and accessors for module-private registries require peers when their identity or state crosses package boundaries; being a value import alone does not make an export duplicate-safe. Changing a classification changes the installed graph and requires the focused verifier tests, the two-release layout check, and a fresh next-package benchmark. The metadata-only benchmark is diagnostic evidence, not a release-time performance promise. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index 090031e9cd..e0ba33ff25 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -56,6 +56,12 @@ pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000 pnpm run benchmark:npm-resolution -- --ref=origin/master --runs=5 --timeout-ms=300000 ``` +通过两个互不兼容的 DSH 合成版本验证包落位。验证器把每份当前 DSH manifest 分别复制为 `0.1.0` 和 `0.2.0`,只要求 npm 生成 package lock,并拒绝跨版本 DSH 解析、非预期 DSH 路径、两套版本清单不一致、多个 Cordis 实例以及包归档请求。本地索引只包含当前平台已安装的 metadata,因此只报告而不拒绝 npm 已接受的不可用可选包探测。 + +```sh +pnpm run verify-npm-install-layout +``` + 计算下一项 Host 包时,命令会在内存中应用当前策略、测量 baseline、逐个尝试可达且未配置的包,并串行复测粗筛中最快的候选。正数 `gainSeconds` 等于 `baseline median - candidate median`;`--candidates` 限定名册,`--jobs` 控制粗筛并发度,两个阶段都不写 manifest。选中的候选仍需先完成导出分类,才能加入 `hostPackages`。 ```sh @@ -64,7 +70,7 @@ pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists ### 性能验证 -[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) 与 [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 保持为手动工具而非 CI 门禁,因为 resolver 耗时会随机器负载和 metadata 完成顺序变化。它们通过全新 consumer 和仅 metadata 的运行,把 npm 依赖树计算与 registry 延迟、包归档下载分离,因此相对结果可以定位 peer 中继,但不构成发布时性能承诺。 +[`verify-npm-install-layout`](../../../../scripts/verify-npm-install-layout.ts) 是 `Release (dsh)` workflow 在每个 pull request 和 master push 上运行的确定性包路径与版本检查;它不限制 resolver 耗时。[`benchmark-npm-resolution`](../../../../scripts/benchmark-npm-resolution.ts) 与 [`benchmark-next-package-dependency`](../../../../scripts/benchmark-next-package-dependency.ts) 保持为手动工具,因为 resolver 耗时会随机器负载和 metadata 完成顺序变化。它们通过全新 consumer 和仅 metadata 的运行,把 npm 依赖树计算与 registry 延迟、包归档下载分离,因此相对结果可以定位 peer 中继,但不构成发布时性能承诺。 ## 考虑过的替代方案 @@ -84,4 +90,4 @@ pnpm run benchmark:npm-resolution:next -- --runs=1 --finalist-runs=5 --finalists 把公开纯类型关系放进 `devDependencies`,意味着独立 TypeScript 消费者在使用该声明时必须自行安装被引用的类型包。发布 profile 会安装完整的受支持包族;若要支持独立组装的 TypeScript 消费者,需要另一套策略。 -显式 override、Host 列表与导出分类都是需要评审的决策。当 class constructor、symbol 和访问模块私有 registry 的函数跨包传递身份或状态时,它们要求 peer;仅仅属于 value import 并不能证明导出可重复安装。修改分类会改变安装图,因此需要运行聚焦 verifier 测试并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。 +显式 override、Host 列表与导出分类都是需要评审的决策。当 class constructor、symbol 和访问模块私有 registry 的函数跨包传递身份或状态时,它们要求 peer;仅仅属于 value import 并不能证明导出可重复安装。修改分类会改变安装图,因此需要运行聚焦 verifier 测试、双版本布局检查并重新执行 next-package benchmark。仅 metadata benchmark 是诊断证据,不是发布时安装耗时承诺。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ae1940c0c..ad6c64ae67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,9 @@ # entries, all on one version. The vendored framework and the native packages are # separate sequences with their own workflows and version lines. # -# Pack runs without credentials on every pull request and master push, so a -# pull request proves the whole publish set still packs. Publication is a manual -# workflow_dispatch of release-publish.yml from a dsh-v* tag. +# Pack and dependency-layout verification run without credentials on every pull +# request and master push. Publication is a manual workflow_dispatch of +# release-publish.yml from a dsh-v* tag. name: Release (dsh) on: @@ -26,6 +26,46 @@ env: DSH_TELEMETRY_DISABLED: '1' jobs: + dependencies: + name: Dependency layout + runs-on: ubuntu-24.04 + 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" + + - 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 + + - name: Verify dependency policy + run: pnpm run verify-package-dependencies + + - name: Verify npm install layout + run: pnpm run verify-npm-install-layout + pack: name: Pack npm tarballs runs-on: ubuntu-24.04 diff --git a/package.json b/package.json index 2ec0a09049..f88f78a491 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", "verify-package-dependencies": "tsx scripts/verify-package-dependencies.ts", + "verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.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", diff --git a/scripts/benchmark-npm-resolution.spec.ts b/scripts/benchmark-npm-resolution.spec.ts index 92a6bdbfcb..e450614a44 100644 --- a/scripts/benchmark-npm-resolution.spec.ts +++ b/scripts/benchmark-npm-resolution.spec.ts @@ -7,6 +7,7 @@ import { buildRegistryIndex, parseBenchmarkOptions, publishWorkspaceRange, + resolveNpmPackageLock, type RegistryIndex, } from './benchmark-npm-resolution.ts' @@ -81,4 +82,26 @@ describe('npm resolution benchmark', () => { expect(result.archiveRequests).toBe(0) expect(result.unknownPackages).toEqual([]) }) + + it('returns npm placement for two aliased package versions without requesting archives', async () => { + const index: RegistryIndex = new Map([[ + '@deepseek-ai/dsh', + new Map([ + ['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }], + ['0.2.0', { name: '@deepseek-ai/dsh', version: '0.2.0' }], + ]), + ]]) + + const result = await resolveNpmPackageLock(index, { + '@deepseek-ai/dsh': '0.2.0', + 'dsh-previous': 'npm:@deepseek-ai/dsh@0.1.0', + }, 10_000) + + expect(result.archiveRequests).toBe(0) + expect(result.packageLock.packages['node_modules/@deepseek-ai/dsh']?.version).toBe('0.2.0') + expect(result.packageLock.packages['node_modules/dsh-previous']).toMatchObject({ + name: '@deepseek-ai/dsh', + version: '0.1.0', + }) + }) }) diff --git a/scripts/benchmark-npm-resolution.ts b/scripts/benchmark-npm-resolution.ts index 57834d4f09..2e7ff71732 100644 --- a/scripts/benchmark-npm-resolution.ts +++ b/scripts/benchmark-npm-resolution.ts @@ -69,6 +69,27 @@ export interface BenchmarkRun { readonly unknownPackages: readonly string[] } +/** Published-package fields retained in npm's package-lock layout. */ +export interface NpmLockPackage { + readonly name?: string + readonly version?: string + readonly dependencies?: Readonly> + readonly optionalDependencies?: Readonly> + readonly peerDependencies?: Readonly> + readonly peerDependenciesMeta?: Readonly> +} + +/** The installed paths selected by npm without materializing package archives. */ +export interface NpmPackageLock { + readonly lockfileVersion: number + readonly packages: Readonly> +} + +/** npm resolution observations together with its computed install layout. */ +export interface NpmPackageLockResolution extends BenchmarkRun { + readonly packageLock: NpmPackageLock +} + /** Parse one positive-integer command-line option or use its default. */ export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number { if (raw === undefined) return fallback @@ -250,6 +271,19 @@ function npmExecutable(): string { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } +function readNpmPackageLock(path: string): NpmPackageLock { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('npm produced an invalid package-lock.json') + } + const { lockfileVersion, packages } = parsed as Record + if (!Number.isSafeInteger(lockfileVersion) || packages === null + || typeof packages !== 'object' || Array.isArray(packages)) { + throw new Error('npm produced an invalid package-lock.json') + } + return parsed as NpmPackageLock +} + async function runNpm( cwd: string, registry: string, @@ -302,17 +336,17 @@ async function runNpm( } /** - * Resolve the CLI install graph once without downloading package archives. + * Ask npm to compute an install layout without downloading package archives. * @param index - Package metadata exposed through the local registry. - * @param targetVersion - Version of `@deepseek-ai/dsh` to install. + * @param dependencies - Root dependencies whose install layout npm computes. * @param timeoutMs - Hard wall-clock limit for the npm child process. - * @returns Timing and registry-request observations. + * @returns The package lock plus timing and registry-request observations. */ -export async function benchmarkNpmResolution( +export async function resolveNpmPackageLock( index: RegistryIndex, - targetVersion: string, + dependencies: Readonly>, timeoutMs: number, -): Promise { +): Promise { let registryRequests = 0 let archiveRequests = 0 const unknownPackages = new Set() @@ -357,7 +391,7 @@ export async function benchmarkNpmResolution( name: 'dsh-npm-resolution-benchmark', version: '0.0.0', private: true, - dependencies: { [TARGET_PACKAGE]: targetVersion }, + dependencies, }, null, 2)}\n`) const result = await runNpm(consumer, registry, timeoutMs) if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`) @@ -366,6 +400,7 @@ export async function benchmarkNpmResolution( registryRequests, archiveRequests, unknownPackages: [...unknownPackages].sort(), + packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')), } } finally { await close(server) @@ -373,6 +408,27 @@ export async function benchmarkNpmResolution( } } +/** + * Resolve the CLI install graph once without downloading package archives. + * @param index - Package metadata exposed through the local registry. + * @param targetVersion - Version of `@deepseek-ai/dsh` to install. + * @param timeoutMs - Hard wall-clock limit for the npm child process. + * @returns Timing and registry-request observations. + */ +export async function benchmarkNpmResolution( + index: RegistryIndex, + targetVersion: string, + timeoutMs: number, +): Promise { + const result = await resolveNpmPackageLock(index, { [TARGET_PACKAGE]: targetVersion }, timeoutMs) + return { + durationMs: result.durationMs, + registryRequests: result.registryRequests, + archiveRequests: result.archiveRequests, + unknownPackages: result.unknownPackages, + } +} + async function main(): Promise { const options = parseBenchmarkOptions(process.argv.slice(2)) const root = resolve(import.meta.dirname, '..') diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 0a59f36ae7..fa9fc052f5 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -603,7 +603,7 @@ describe('npm release workflows', () => { for (const file of ['release.yml', 'release-vendor.yml']) { const workflow = loadWorkflow(`.github/workflows/${file}`) if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`) - expect(Object.keys(workflow.jobs).sort()).toEqual(['pack']) + expect(Object.keys(workflow.jobs).sort()).toEqual(file === 'release.yml' ? ['dependencies', 'pack'] : ['pack']) } // publication is workflow_dispatch-only (never a PR check) and keeps the @@ -618,6 +618,20 @@ describe('npm release workflows', () => { expect(publish.concurrency).toMatchObject({ group: 'Release-publish' }) } }) + + it('runs dependency policy and npm layout checks in the DSH release workflow', () => { + const workflow = loadWorkflow('.github/workflows/release.yml') + const dependencies = workflowJob(workflow, 'dependencies') + if (!isRecord(workflow.on) || !Array.isArray(dependencies.steps)) { + throw new TypeError('DSH release workflow must define triggers and dependency steps') + } + const commands = dependencies.steps.flatMap(step => + isRecord(step) && typeof step.run === 'string' ? [step.run] : []) + + expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch']) + expect(commands).toContain('pnpm run verify-package-dependencies') + expect(commands).toContain('pnpm run verify-npm-install-layout') + }) }) describe('Documentation site publication', () => { diff --git a/scripts/verify-npm-install-layout.spec.ts b/scripts/verify-npm-install-layout.spec.ts new file mode 100644 index 0000000000..3c36c9bdd7 --- /dev/null +++ b/scripts/verify-npm-install-layout.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { NpmPackageLock, RegistryIndex } from './benchmark-npm-resolution.ts' +import { + assertDualDshInstallLayout, + buildDualDshRegistry, +} from './verify-npm-install-layout.ts' + +function validLayout(): NpmPackageLock { + return { + lockfileVersion: 3, + packages: { + '': { dependencies: { '@deepseek-ai/dsh': '0.2.0', 'dsh-previous': 'npm:@deepseek-ai/dsh@0.1.0' } }, + 'node_modules/@deepseek-ai/cordis': { version: '4.0.1' }, + 'node_modules/@deepseek-ai/dsh': { + version: '0.2.0', + dependencies: { '@deepseek-ai/dsh-child': '^0.2.0' }, + peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' }, + }, + 'node_modules/@deepseek-ai/dsh-child': { + version: '0.2.0', + dependencies: { '@deepseek-ai/dsh-leaf': '^0.2.0' }, + }, + 'node_modules/@deepseek-ai/dsh-leaf': { version: '0.2.0' }, + 'node_modules/dsh-previous': { + name: '@deepseek-ai/dsh', + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-child': '^0.1.0' }, + peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' }, + }, + 'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-child': { + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-leaf': '^0.1.0' }, + }, + 'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-leaf': { version: '0.1.0' }, + }, + } +} + +describe('npm install layout verifier', () => { + it('creates two incompatible versions of every DSH package', () => { + const index: RegistryIndex = new Map([ + ['@deepseek-ai/dsh', new Map([['0.1.1-rc.2', { + name: '@deepseek-ai/dsh', + version: '0.1.1-rc.2', + dependencies: { '@deepseek-ai/dsh-child': '^0.1.1-rc.2' }, + peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' }, + }]])], + ['@deepseek-ai/dsh-child', new Map([['0.1.1-rc.2', { + name: '@deepseek-ai/dsh-child', + version: '0.1.1-rc.2', + }]])], + ['@deepseek-ai/cordis', new Map([['4.0.1', { + name: '@deepseek-ai/cordis', + version: '4.0.1', + }]])], + ]) + + const dual = buildDualDshRegistry(index, '0.1.1-rc.2') + + expect([...dual.get('@deepseek-ai/dsh')?.keys() ?? []]).toEqual(['0.1.0', '0.2.0']) + expect(dual.get('@deepseek-ai/dsh')?.get('0.1.0')).toMatchObject({ + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-child': '^0.1.0' }, + peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' }, + }) + expect(dual.get('@deepseek-ai/dsh')?.get('0.2.0')).toMatchObject({ + version: '0.2.0', + dependencies: { '@deepseek-ai/dsh-child': '^0.2.0' }, + }) + expect(dual.get('@deepseek-ai/cordis')).toBe(index.get('@deepseek-ai/cordis')) + }) + + it('accepts isolated DSH releases with one shared Cordis installation', () => { + expect(assertDualDshInstallLayout(validLayout())).toEqual({ + dshPackagesPerVersion: 3, + checkedDshEdges: 4, + }) + }) + + it('rejects an internal edge that crosses release versions', () => { + const layout = validLayout() + const packages = { ...layout.packages } + Reflect.deleteProperty(packages, 'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-leaf') + + expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow( + 'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-child: dependencies ' + + '@deepseek-ai/dsh-leaf resolves to node_modules/@deepseek-ai/dsh-leaf@0.2.0, expected 0.1.0', + ) + }) + + it('rejects a second Cordis installation', () => { + const layout = validLayout() + const packages = { + ...layout.packages, + 'node_modules/dsh-previous/node_modules/@deepseek-ai/cordis': { version: '4.0.1' }, + } + + expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow( + 'expected one shared @deepseek-ai/cordis', + ) + }) +}) diff --git a/scripts/verify-npm-install-layout.ts b/scripts/verify-npm-install-layout.ts new file mode 100644 index 0000000000..9a125ea864 --- /dev/null +++ b/scripts/verify-npm-install-layout.ts @@ -0,0 +1,217 @@ +/** Verify npm's physical package placement for two incompatible DSH releases. */ + +import { readFileSync } from 'node:fs' +import { posix, resolve } from 'node:path' +import { + buildRegistryIndex, + resolveNpmPackageLock, + type NpmLockPackage, + type NpmPackageLock, + type RegistryIndex, +} from './benchmark-npm-resolution.ts' + +const DSH_PACKAGE = '@deepseek-ai/dsh' +const CORDIS_PACKAGE = '@deepseek-ai/cordis' +const NESTED_DSH_ALIAS = 'dsh-previous' +const NESTED_DSH_PATH = `node_modules/${NESTED_DSH_ALIAS}` +const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const +const TIMEOUT_MS = 300_000 + +/** Synthetic incompatible versions used to expose cross-release placement errors. */ +export const SYNTHETIC_DSH_VERSIONS = ['0.1.0', '0.2.0'] as const + +interface MutableRegistryManifest { + name: string + version: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + [key: string]: unknown +} + +/** Summary of a verified two-release npm layout. */ +export interface DshInstallLayoutSummary { + readonly dshPackagesPerVersion: number + readonly checkedDshEdges: number +} + +function isDshPackage(name: string): boolean { + return name === DSH_PACKAGE || name.startsWith(`${DSH_PACKAGE}-`) +} + +function cloneForVersion(manifest: object, version: string): MutableRegistryManifest { + const cloned = structuredClone(manifest) as MutableRegistryManifest + cloned.version = version + for (const field of DEPENDENCY_FIELDS) { + const dependencies = cloned[field] + if (dependencies === undefined) continue + for (const name of Object.keys(dependencies)) { + if (isDshPackage(name)) dependencies[name] = `^${version}` + } + } + return cloned +} + +/** + * Replace the working release with two incompatible, internally consistent DSH releases. + * @param index - Registry metadata containing the working release. + * @param sourceVersion - Workspace version copied into each synthetic release. + * @returns Registry metadata containing both synthetic DSH releases and unchanged external packages. + */ +export function buildDualDshRegistry(index: RegistryIndex, sourceVersion: string): RegistryIndex { + const output = new Map(index) + let dshPackages = 0 + for (const [name, versions] of index) { + if (!isDshPackage(name)) { + output.set(name, versions) + continue + } + const source = versions.get(sourceVersion) + if (source === undefined) throw new Error(`${name} has no workspace version ${sourceVersion}`) + dshPackages++ + output.set(name, new Map(SYNTHETIC_DSH_VERSIONS.map(version => [ + version, + cloneForVersion(source, version), + ]))) + } + if (dshPackages === 0) throw new Error('registry contains no DSH packages') + return output +} + +function packageNameAtPath(path: string, manifest: NpmLockPackage): string | undefined { + if (manifest.name !== undefined) return manifest.name + const marker = 'node_modules/' + const markerIndex = path.lastIndexOf(marker) + if (markerIndex < 0) return undefined + const segments = path.slice(markerIndex + marker.length).split('/') + if (segments[0]?.startsWith('@')) { + return segments[1] === undefined ? undefined : `${segments[0]}/${segments[1]}` + } + return segments[0] +} + +function resolvePackagePath( + packages: Readonly>, + sourcePath: string, + dependency: string, +): string | undefined { + let directory = sourcePath + while (directory !== '.') { + const candidate = posix.join(directory, 'node_modules', dependency) + if (packages[candidate] !== undefined) return candidate + directory = posix.dirname(directory) + } + const rootCandidate = posix.join('node_modules', dependency) + return packages[rootCandidate] === undefined ? undefined : rootCandidate +} + +function setDifference(left: ReadonlySet, right: ReadonlySet): string[] { + return [...left].filter(value => !right.has(value)).sort() +} + +/** + * Assert that npm isolates both DSH releases while sharing the Cordis runtime. + * @param packageLock - Metadata-only package lock produced by npm. + * @returns Counts for the verified DSH packages and dependency edges. + */ +export function assertDualDshInstallLayout(packageLock: NpmPackageLock): DshInstallLayoutSummary { + const [nestedVersion, rootVersion] = SYNTHETIC_DSH_VERSIONS + const errors: string[] = [] + const namesByVersion = new Map>([ + [nestedVersion, new Set()], + [rootVersion, new Set()], + ]) + const installed = Object.entries(packageLock.packages) + let checkedDshEdges = 0 + + for (const [path, manifest] of installed) { + const name = packageNameAtPath(path, manifest) + if (name === undefined || !isDshPackage(name)) continue + const version = manifest.version + if (version !== nestedVersion && version !== rootVersion) { + errors.push(`${path}: expected DSH version ${nestedVersion} or ${rootVersion}, got ${String(version)}`) + continue + } + namesByVersion.get(version)?.add(name) + const expectedPath = version === rootVersion + ? `node_modules/${name}` + : name === DSH_PACKAGE + ? NESTED_DSH_PATH + : `${NESTED_DSH_PATH}/node_modules/${name}` + if (path !== expectedPath) { + errors.push(`${path}: expected ${name}@${version} at ${expectedPath}`) + } + + for (const field of DEPENDENCY_FIELDS) { + for (const dependency of Object.keys(manifest[field] ?? {})) { + if (!isDshPackage(dependency)) continue + const targetPath = resolvePackagePath(packageLock.packages, path, dependency) + const optionalPeer = field === 'peerDependencies' + && manifest.peerDependenciesMeta?.[dependency]?.optional === true + if (targetPath === undefined) { + if (field === 'optionalDependencies' || optionalPeer) continue + errors.push(`${path}: ${field} ${dependency} does not resolve`) + continue + } + checkedDshEdges++ + const targetVersion = packageLock.packages[targetPath]?.version + if (targetVersion !== version) { + errors.push( + `${path}: ${field} ${dependency} resolves to ${targetPath}@${String(targetVersion)}, expected ${version}`, + ) + } + } + } + } + + const nestedNames = namesByVersion.get(nestedVersion) ?? new Set() + const rootNames = namesByVersion.get(rootVersion) ?? new Set() + if (!nestedNames.has(DSH_PACKAGE)) errors.push(`${NESTED_DSH_PATH}: missing ${DSH_PACKAGE}@${nestedVersion}`) + if (!rootNames.has(DSH_PACKAGE)) errors.push(`node_modules/${DSH_PACKAGE}: missing ${DSH_PACKAGE}@${rootVersion}`) + const onlyNested = setDifference(nestedNames, rootNames) + const onlyRoot = setDifference(rootNames, nestedNames) + if (onlyNested.length > 0) errors.push(`only ${nestedVersion} contains: ${onlyNested.join(', ')}`) + if (onlyRoot.length > 0) errors.push(`only ${rootVersion} contains: ${onlyRoot.join(', ')}`) + + const cordisPaths = installed.flatMap(([path, manifest]) => + packageNameAtPath(path, manifest) === CORDIS_PACKAGE ? [path] : []) + if (cordisPaths.length !== 1 || cordisPaths[0] !== `node_modules/${CORDIS_PACKAGE}`) { + errors.push(`expected one shared ${CORDIS_PACKAGE} at node_modules/${CORDIS_PACKAGE}, got ${cordisPaths.join(', ')}`) + } + + if (errors.length > 0) throw new Error(`invalid npm install layout:\n${errors.map(error => ` - ${error}`).join('\n')}`) + return { dshPackagesPerVersion: rootNames.size, checkedDshEdges } +} + +function workspaceVersion(root: string): string { + const manifest = JSON.parse(readFileSync(resolve(root, 'apps/cli/package.json'), 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error('apps/cli/package.json has no string version') + return manifest.version +} + +async function main(): Promise { + const root = resolve(import.meta.dirname, '..') + const index = buildDualDshRegistry(buildRegistryIndex(root), workspaceVersion(root)) + const [nestedVersion, rootVersion] = SYNTHETIC_DSH_VERSIONS + const result = await resolveNpmPackageLock(index, { + [DSH_PACKAGE]: rootVersion, + [NESTED_DSH_ALIAS]: `npm:${DSH_PACKAGE}@${nestedVersion}`, + }, TIMEOUT_MS) + if (result.archiveRequests !== 0) throw new Error(`npm requested ${String(result.archiveRequests)} package archive(s)`) + const summary = assertDualDshInstallLayout(result.packageLock) + console.log( + `verify-npm-install-layout: ${String(summary.dshPackagesPerVersion)} DSH package(s) per release and ` + + `${String(summary.checkedDshEdges)} internal edge(s) verified in ${(result.durationMs / 1000).toFixed(2)} s; ` + + `both releases share one Cordis installation; ${String(result.unknownPackages.length)} unavailable optional ` + + 'package name(s) ignored by npm.', + ) +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error(`verify-npm-install-layout: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} From 9162bc69bd9c1e681ef4266a394e133ef31c78ae Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:03:00 +0800 Subject: [PATCH 078/429] fix(release): harden dependency verification --- ...08-26-published-dependency-faces.i18n.yaml | 4 +- .../2026-08-26-published-dependency-faces.md | 6 +- ...026-08-26-published-dependency-faces.zh.md | 6 +- .../benchmark-next-package-dependency.spec.ts | 1 + scripts/benchmark-npm-resolution.spec.ts | 75 ++++++++++ scripts/benchmark-npm-resolution.ts | 138 ++++++++++++++---- scripts/package-dependency-policy.ts | 6 +- scripts/verify-package-dependencies.spec.ts | 75 +++++++++- scripts/verify-package-dependencies.ts | 29 ++-- 9 files changed, 287 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index e09edf4f86..3604d6fe6a 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: aa7714e5c83aaf1c68ef300bdb10006e534bf88d -2026-08-26-published-dependency-faces.zh.md: e0ba33ff25651d1adf378af87fd6065ed9e54d03 +2026-08-26-published-dependency-faces.md: 668d4c721446a87388ae50951156d1b24f91138c +2026-08-26-published-dependency-faces.zh.md: b10a285a81490f8eb66d70d3e73c2f676ad1b35c diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index aa7714e5c8..668d4c7214 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -26,11 +26,11 @@ Every covered package keeps `@deepseek-ai/cordis` in matching `peerDependencies` A workspace package reached by a runtime value import from the Host entry closure belongs only in `dependencies` when every imported runtime export appears in the policy's `safeHostDependencyExports` table. An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports present in neither table; namespace, dynamic, and side-effect imports remain unbounded and cannot enter either table. -Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Existing third-party dependencies outside these managed relationships keep their declared section. Workspace references use `workspace:^`. +Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`. Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`. -The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata. +The verifier reads source manifests and source files, so it runs on a clean tree without built `lib/`. Every selected Host face must have `src/index.ts`. An unclassified Host runtime export is a policy violation that blocks all `--fix` writes; a maintainer must review the export and classify it, change the source relationship, or change the package selection. Once source safety passes, `--fix` performs only the section and range changes implied by the classification and removes stale peer metadata. ### Maintainer workflow @@ -49,7 +49,7 @@ pnpm run verify-package-dependencies -- --fix git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml ``` -Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` bounds each run, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold. +Measure the working-tree graph and a Git ref through the local metadata-only registry. Each run creates a fresh consumer and npm cache, replaces inherited npm configuration with explicit peer, hoisting, and registry settings, executes `npm install --package-lock-only`, rejects archive downloads, and leaves the repository unchanged. `--runs` controls repetitions, `--timeout-ms` terminates the npm process tree after its deadline, and optional `--max-ms` makes the command fail when the slowest run exceeds a threshold. ```sh pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index e0ba33ff25..b10a285a81 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -26,11 +26,11 @@ Host-only 包通过另一份显式列表加入同一策略。该列表包含 `@d Host 入口闭包中的运行期 value import 所到达的 workspace 包,只有在每个运行期导出都列入策略的 `safeHostDependencyExports` 表时才只属于 `dependencies`。constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。表的每个 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝两个表都未收录的导出;namespace、dynamic 和 side-effect import 无法限定导出范围,因此不能进入任一表。 -Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。不属于这些受管关系的现有第三方 dependency 保持原区段。Workspace 引用使用 `workspace:^`。 +Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。 部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。 -验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 +验证器读取源码 manifest 和源码文件,因此可以在没有已构建 `lib/` 的干净工作树上运行。每个被选中的 Host face 都必须存在 `src/index.ts`。未分类的 Host 运行期导出属于策略违规,会阻止 `--fix` 的全部写入;维护者必须审查该导出,并选择分类该导出、修改源码关系或修改选包范围。源码安全检查通过后,`--fix` 只执行分类所确定的区段与范围变更,并删除失效的 peer 元数据。 ### 维护流程 @@ -49,7 +49,7 @@ pnpm run verify-package-dependencies -- --fix git diff -- packages pnpm-lock.yaml docs/module-graph.md docs/module-graph.zh.md docs/module-graph.i18n.yaml ``` -通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 限制单轮耗时,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。 +通过仅 metadata 的本地 registry 测量工作树依赖图与 Git ref。每轮都会创建全新 consumer 与 npm cache,用明确的 peer、hoisting 和 registry 设置替换继承的 npm 配置,执行 `npm install --package-lock-only`,拒绝下载包归档,并保持仓库不变。`--runs` 控制重复次数,`--timeout-ms` 会在期限到达后终止 npm 进程树,可选 `--max-ms` 会在最慢一轮超过阈值时让命令失败。 ```sh pnpm run benchmark:npm-resolution -- --runs=5 --timeout-ms=300000 diff --git a/scripts/benchmark-next-package-dependency.spec.ts b/scripts/benchmark-next-package-dependency.spec.ts index 2a0ee10189..489b72c3cf 100644 --- a/scripts/benchmark-next-package-dependency.spec.ts +++ b/scripts/benchmark-next-package-dependency.spec.ts @@ -73,6 +73,7 @@ describe('next package benchmark graph', () => { sourceLine: "import { runtimeValue } from '@f/runtime'", }], peerRequiredHostDependencies: new Set(), + configurationOnlyDevDependencies: new Set(), clientInject: new Set(), } const index = new Map>([ diff --git a/scripts/benchmark-npm-resolution.spec.ts b/scripts/benchmark-npm-resolution.spec.ts index e450614a44..7511cb34c5 100644 --- a/scripts/benchmark-npm-resolution.spec.ts +++ b/scripts/benchmark-npm-resolution.spec.ts @@ -8,6 +8,7 @@ import { parseBenchmarkOptions, publishWorkspaceRange, resolveNpmPackageLock, + runCommandWithTimeout, type RegistryIndex, } from './benchmark-npm-resolution.ts' @@ -104,4 +105,78 @@ describe('npm resolution benchmark', () => { version: '0.1.0', }) }) + + it('isolates peer resolution from inherited npm configuration', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hostile-npm-config-')) + roots.push(root) + const userConfig = join(root, 'user.npmrc') + writeFileSync(userConfig, '@deepseek-ai:registry=http://127.0.0.1:1/\nlegacy-peer-deps=true\nomit=peer\n') + const previous = { + userConfig: process.env.npm_config_userconfig, + legacyPeerDeps: process.env.npm_config_legacy_peer_deps, + omit: process.env.npm_config_omit, + } + process.env.npm_config_userconfig = userConfig + process.env.npm_config_legacy_peer_deps = 'true' + process.env.npm_config_omit = 'peer' + try { + const index: RegistryIndex = new Map([ + ['@deepseek-ai/dsh', new Map([['0.1.0', { + name: '@deepseek-ai/dsh', + version: '0.1.0', + peerDependencies: { '@deepseek-ai/dsh-peer': '1.0.0' }, + }]])], + ['@deepseek-ai/dsh-peer', new Map([['1.0.0', { + name: '@deepseek-ai/dsh-peer', + version: '1.0.0', + }]])], + ]) + + const result = await resolveNpmPackageLock(index, { '@deepseek-ai/dsh': '0.1.0' }, 10_000) + + expect(result.archiveRequests).toBe(0) + expect(result.packageLock.packages['node_modules/@deepseek-ai/dsh-peer']?.version).toBe('1.0.0') + } finally { + if (previous.userConfig === undefined) delete process.env.npm_config_userconfig + else process.env.npm_config_userconfig = previous.userConfig + if (previous.legacyPeerDeps === undefined) delete process.env.npm_config_legacy_peer_deps + else process.env.npm_config_legacy_peer_deps = previous.legacyPeerDeps + if (previous.omit === undefined) delete process.env.npm_config_omit + else process.env.npm_config_omit = previous.omit + } + }) + + it.skipIf(process.platform === 'win32')('force-kills a timed-out process tree', async () => { + const source = [ + "const { spawn } = require('node:child_process')", + "process.on('SIGTERM', () => {})", + 'const child = spawn(process.execPath, [\'-e\', "process.on(\'SIGTERM\', () => {}); setInterval(() => {}, 1000)"], { stdio: \'ignore\' })', + 'console.log(child.pid)', + 'setInterval(() => {}, 1000)', + ].join(';') + let descendantPid: number | undefined + try { + const result = await runCommandWithTimeout(process.execPath, ['-e', source], { + cwd: process.cwd(), + env: process.env, + timeoutMs: 1_000, + terminationGraceMs: 100, + }) + const reportedPid = Number.parseInt(result.output.trim(), 10) + if (!Number.isSafeInteger(reportedPid)) throw new Error(`child reported invalid pid ${result.output.trim()}`) + descendantPid = reportedPid + + expect(result.timedOut).toBe(true) + expect(result.signal).toBe('SIGKILL') + expect(() => { process.kill(reportedPid, 0) }).toThrow() + } finally { + if (descendantPid !== undefined && Number.isSafeInteger(descendantPid)) { + try { + process.kill(descendantPid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } + } + }) }) diff --git a/scripts/benchmark-npm-resolution.ts b/scripts/benchmark-npm-resolution.ts index 2e7ff71732..b8089117ad 100644 --- a/scripts/benchmark-npm-resolution.ts +++ b/scripts/benchmark-npm-resolution.ts @@ -1,6 +1,6 @@ /** Benchmark npm's dependency-tree resolution against an all-local registry. */ -import { execFileSync, spawn } from 'node:child_process' +import { execFileSync, spawn, spawnSync, type ChildProcess } from 'node:child_process' import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { tmpdir } from 'node:os' @@ -10,6 +10,8 @@ import { parseArgs } from 'node:util' const TARGET_PACKAGE = '@deepseek-ai/dsh' const DEFAULT_TIMEOUT_MS = 300_000 +const TERMINATION_GRACE_MS = 1_000 +const FORCED_EXIT_TIMEOUT_MS = 5_000 const WORKSPACE_MANIFEST_GLOBS = [ 'apps/*/package.json', 'packages/*/*/package.json', @@ -271,6 +273,92 @@ function npmExecutable(): string { return process.platform === 'win32' ? 'npm.cmd' : 'npm' } +function delay(ms: number): Promise { + return new Promise(resolveDelay => setTimeout(resolveDelay, ms)) +} + +function signalProcessTree(child: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void { + if (child.pid === undefined) { + child.kill(signal) + return + } + if (process.platform === 'win32') { + const force = signal === 'SIGKILL' ? ['/F'] : [] + const result = spawnSync('taskkill', ['/PID', String(child.pid), '/T', ...force], { + stdio: 'ignore', + windowsHide: true, + }) + if (result.error !== undefined) throw result.error + if (result.status !== 0 && child.exitCode === null && child.signalCode === null) child.kill(signal) + return + } + try { + process.kill(-child.pid, signal) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } +} + +/** + * Run one command with bounded process-tree termination after its deadline. + * @param command - Executable path or name. + * @param args - Arguments passed without shell interpolation on POSIX. + * @param options - Working directory, environment, timeout, and termination grace. + * @returns Exit facts, captured output, duration, and whether timeout handling began. + */ +export async function runCommandWithTimeout( + command: string, + args: readonly string[], + options: { + readonly cwd: string + readonly env: NodeJS.ProcessEnv + readonly timeoutMs: number + readonly terminationGraceMs?: number + }, +): Promise<{ status: number | null; signal: NodeJS.Signals | null; durationMs: number; output: string; timedOut: boolean }> { + const started = performance.now() + const child = spawn(command, [...args], { + cwd: options.cwd, + detached: process.platform !== 'win32', + env: options.env, + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { output += String(chunk) }) + child.stderr.on('data', (chunk) => { output += String(chunk) }) + const exited = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolveExit, reject) => { + child.once('error', reject) + child.once('close', (status, signal) => { resolveExit({ status, signal }) }) + }) + let timeout: NodeJS.Timeout | undefined + try { + const first = await Promise.race([ + exited.then(outcome => ({ type: 'exit' as const, outcome })), + new Promise<{ type: 'timeout' }>((resolveTimeout) => { + timeout = setTimeout(() => { resolveTimeout({ type: 'timeout' }) }, options.timeoutMs) + }), + ]) + if (first.type === 'exit') { + return { ...first.outcome, durationMs: performance.now() - started, output, timedOut: false } + } + + signalProcessTree(child, 'SIGTERM') + await delay(options.terminationGraceMs ?? TERMINATION_GRACE_MS) + signalProcessTree(child, 'SIGKILL') + const forced = await Promise.race([ + exited, + delay(FORCED_EXIT_TIMEOUT_MS).then(() => undefined), + ]) + if (forced === undefined) throw new Error('timed-out process tree did not exit after SIGKILL') + return { ...forced, durationMs: performance.now() - started, output, timedOut: true } + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + function readNpmPackageLock(path: string): NpmPackageLock { const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { @@ -289,50 +377,39 @@ async function runNpm( registry: string, timeoutMs: number, ): Promise<{ durationMs: number; output: string; timedOut: boolean }> { - const started = performance.now() - const child = spawn(npmExecutable(), [ + const npmrc = join(cwd, '.npmrc') + const globalNpmrc = join(cwd, '.npmrc-global') + writeFileSync(npmrc, `registry=${registry}\n@deepseek-ai:registry=${registry}\n`) + writeFileSync(globalNpmrc, '') + const inheritedEnvironment = Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))) + const result = await runCommandWithTimeout(npmExecutable(), [ 'install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error', + '--include=peer', + '--install-strategy=hoisted', + '--legacy-peer-deps=false', `--registry=${registry}`, ], { cwd, - // Windows resolves npm through a .cmd shim, which spawn() refuses - // without a shell since the CVE-2024-27980 hardening. - shell: process.platform === 'win32', env: { - ...process.env, + ...inheritedEnvironment, npm_config_cache: join(cwd, '.npm-cache'), + npm_config_globalconfig: globalNpmrc, + npm_config_userconfig: npmrc, npm_config_update_notifier: 'false', }, - stdio: ['ignore', 'pipe', 'pipe'], + timeoutMs, }) - let output = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk) => { output += String(chunk) }) - child.stderr.on('data', (chunk) => { output += String(chunk) }) - const outcome = await new Promise<{ status: number | null; timedOut: boolean }>((resolveExit, reject) => { - let timeoutReached = false - const timer = setTimeout(() => { - timeoutReached = true - child.kill('SIGTERM') - }, timeoutMs) - child.once('error', reject) - child.once('exit', (status) => { - clearTimeout(timer) - resolveExit({ status, timedOut: timeoutReached }) - }) - }) - const durationMs = performance.now() - started - if (outcome.timedOut) return { durationMs, output, timedOut: true } - if (outcome.status !== 0) { - throw new Error(`npm install exited ${String(outcome.status)} after ${durationMs.toFixed(0)} ms\n${output.trim()}`) + if (result.timedOut) return result + if (result.status !== 0) { + throw new Error(`npm install exited ${String(result.status)} after ${result.durationMs.toFixed(0)} ms\n${result.output.trim()}`) } - return { durationMs, output, timedOut: false } + return result } /** @@ -403,6 +480,7 @@ export async function resolveNpmPackageLock( packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')), } } finally { + server.closeAllConnections() await close(server) rmSync(consumer, { recursive: true, force: true }) } diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 2d8f7eb56c..50ed98bd17 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -35,14 +35,14 @@ const CONFIGURATION_ONLY_DEV_DEPENDENCIES = { const SAFE_HOST_DEPENDENCY_EXPORTS = { '@deepseek-ai/dsh-api-session-controller/remote-events': ['SESSION_CONTROLLER_REMOTE_EVENTS'], '@deepseek-ai/dsh-credentials': ['credentialKey'], - '@deepseek-ai/dsh-host-apiproxy': ['toFetchHandler'], - '@deepseek-ai/dsh-host-apiproxy/api': ['RpcId', 'clientRequestSchema'], '@deepseek-ai/dsh-llm': ['MessageId', 'callConfigEquals', 'deepFreeze', 'freezeMessage'], '@deepseek-ai/dsh-llm/brand': ['ToolCallId'], '@deepseek-ai/dsh-session': ['isJsonValue'], + '@deepseek-ai/dsh-session/types': ['SessionId'], '@deepseek-ai/dsh-settings': ['settingsNamespace'], '@deepseek-ai/dsh-system-prompt': ['FIRST_PARTY_SECTION_ORDER'], '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'], + '@deepseek-ai/dsh-typert-protocol': ['RemoteError', 'remoteErrorOf'], '@deepseek-ai/dsh-util-crypto': ['randomUUID'], '@deepseek-ai/schemastery': ['default'], } as const satisfies HostDependencyExports @@ -50,7 +50,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], - '@deepseek-ai/dsh-typert-protocol': ['TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods'], + '@deepseek-ai/dsh-typert-protocol': ['Remote', 'TypertRemoteService', 'remoteMethods'], } as const satisfies HostDependencyExports /** Exact import specifier to reviewed runtime exports. */ diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index ad4752f9a7..a59e88a215 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -82,6 +82,7 @@ function facts(manifest: PackageDependencyManifest): PackageDependencyFacts { sourceLine: "import { runtimeValue } from '@deepseek-ai/dsh-runtime'", }], peerRequiredHostDependencies: new Set(), + configurationOnlyDevDependencies: new Set(), clientInject: new Set(), } } @@ -109,11 +110,17 @@ describe('package dependency scope', () => { '@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'], }) expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default']) + expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-session/types']).toEqual([ + 'SessionId', + ]) + expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-typert-protocol']).toEqual([ + 'RemoteError', 'remoteErrorOf', + ]) expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([ 'carrierKeyOf', 'scopeOf', 'scopeTarget', ]) expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toEqual([ - 'TypertLookupFailure', 'TypertRemoteFailure', 'remoteMethods', + 'Remote', 'TypertRemoteService', 'remoteMethods', ]) }) @@ -187,6 +194,7 @@ describe('package dependency scope', () => { sourceLine: "import { safeValue } from '@f/provider/api'", }], peerRequiredHostDependencies: new Set(), + configurationOnlyDevDependencies: new Set(), clientInject: new Set(), } @@ -211,6 +219,15 @@ describe('package dependency scope', () => { }) describe('face-aware source classification', () => { + it('fails when a managed Host package has no Host entry', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-missing-host-')) + roots.push(root) + const subject = pkg('@f/host', 'packages/g/host/package.json') + + expect(() => readPackageDependencyFacts(root, subject, 'configured-host', new Set([subject.name]))) + .toThrow('packages/g/host/package.json: Host runtime entry packages/g/host/src/index.ts does not exist') + }) + it('counts Host values as dependencies and Client values as development inputs', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-')) roots.push(root) @@ -234,9 +251,12 @@ describe('face-aware source classification', () => { } const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([ CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/hidden', '@f/browser', '@f/injected', - ])) + ]), policy({ + configurationOnlyDevDependencies: { '@f/dual': ['@f/injected'] }, + })) expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime']) + expect([...found.configurationOnlyDevDependencies]).toEqual(['@f/injected']) expect(found.hostRuntimeExportUses).toEqual([ { packageName: '@f/nested', @@ -294,6 +314,57 @@ describe('face-aware source classification', () => { }) describe('dependency sections', () => { + it('does not leak repository configuration into captured dependency facts', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-client-locale', + dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' }, + peerDependencies: { [CORDIS]: 'workspace:^' }, + } + const base = facts(manifest) + const subject: PackageDependencyFacts = { + ...base, + workspaceNames: new Set([...base.workspaceNames, '@deepseek-ai/dsh-api-remotes']), + } + + expect(collectPackageDependencyViolations({ + facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames, + })).toEqual([]) + }) + + it('requires non-workspace Host runtime imports in dependencies', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^', external: '^1.0.0' }, + peerDependencies: { [CORDIS]: 'workspace:^' }, + } + const subject: PackageDependencyFacts = { + ...facts(manifest), + hostRuntimeSourceUses: new Map([ + ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']], + ['external', ['packages/core/probe/src/index.ts']], + ]), + } + const state = { + facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames, + } + + expect(collectPackageDependencyViolations(state)).toContain( + 'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) ' + + 'must be dependencies-only; found devDependencies', + ) + repairPackageDependencyManifest(subject) + expect(manifest.dependencies?.external).toBe('^1.0.0') + expect(manifest.devDependencies?.external).toBeUndefined() + + delete manifest.dependencies?.external + expect(collectPackageDependencyViolations(state)).toContain( + 'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) ' + + 'must be dependencies-only; found no dependency section', + ) + }) + it('accepts Host dependencies, development-only inputs, and shared Cordis', () => { const manifest: PackageDependencyManifest = { name: '@deepseek-ai/dsh-probe', diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index 3782d61309..b20e16af00 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -59,6 +59,7 @@ export interface PackageDependencyFacts { readonly hostRuntimeSourceUses: ReadonlyMap readonly hostRuntimeExportUses: readonly HostRuntimeExportUse[] readonly peerRequiredHostDependencies: ReadonlySet + readonly configurationOnlyDevDependencies: ReadonlySet readonly clientInject: ReadonlySet } @@ -296,7 +297,7 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): { const seen = new Set() const visit = (path: string): void => { const normalized = normalize(path) - if (seen.has(normalized) || !existsSync(normalized)) return + if (seen.has(normalized)) return seen.add(normalized) const source = readFileSync(normalized, 'utf8') const displayPath = normalizePath(relative(root, normalized)) @@ -312,7 +313,11 @@ function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): { if (target !== undefined) visit(target) } } - visit(resolve(root, pkg.dir, 'src/index.ts')) + const entry = resolve(root, pkg.dir, 'src/index.ts') + if (!existsSync(entry)) { + throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`) + } + visit(entry) return { packageUses, exportUses: [...exportUses.values()].sort((left, right) => @@ -358,6 +363,9 @@ export function readPackageDependencyFacts( peerRequiredHostDependencies: new Set(hostRuntime.exportUses .filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) .map(use => use.packageName)), + configurationOnlyDevDependencies: new Set( + policy.configurationOnlyDevDependencies[pkg.manifest.name ?? ''] ?? [], + ), clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)), } } @@ -405,7 +413,7 @@ export function collectHostDependencyExportPolicyViolations( for (const fact of facts) { for (const use of fact.hostRuntimeExportUses) { if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue - if (!workspaceNames.has(use.packageName) && fact.manifest.peerDependencies?.[use.packageName] === undefined) continue + if (!workspaceNames.has(use.packageName)) continue if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue violations.push( @@ -427,12 +435,16 @@ export function readPackageDependencyState( const discovered = discoverPackageDependencyScope(packages.release, policy) const facts = discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy)) + const selectedNames = new Set(facts.map(fact => fact.manifest.name)) return { facts, packages: packages.release, policyViolations: [ ...discovered.violations, ...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy), + ...Object.keys(policy.configurationOnlyDevDependencies) + .filter(name => !selectedNames.has(name)) + .map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`), ].sort(), workspaceNames, } @@ -441,7 +453,6 @@ export function readPackageDependencyState( /** Derive the required npm section for each relationship owned by the policy. */ export function expectedPackageDependencies( facts: PackageDependencyFacts, - policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, ): ReadonlyMap { const expected = new Map }>() const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => { @@ -463,18 +474,16 @@ export function expectedPackageDependencies( for (const name of facts.clientInject) { if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject') } - for (const name of PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) { - if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship') - } - for (const name of policy.configurationOnlyDevDependencies[facts.manifest.name ?? ''] ?? []) { + for (const name of facts.configurationOnlyDevDependencies) { if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship') } for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) { if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer') } for (const [name, paths] of facts.hostRuntimeSourceUses) { - if (!facts.workspaceNames.has(name) && facts.manifest.peerDependencies?.[name] === undefined) continue - const expectedSection = facts.peerRequiredHostDependencies.has(name) ? 'peer-dev' : 'dependencies' + const expectedSection = facts.workspaceNames.has(name) && facts.peerRequiredHostDependencies.has(name) + ? 'peer-dev' + : 'dependencies' for (const path of paths) add(name, expectedSection, path) } return new Map([...expected].map(([name, rule]) => [name, { From a3207a758b029d764dc4fc029870336b0c1286f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:29:04 +0800 Subject: [PATCH 079/429] chore: package.json update --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 709 ++++------------- docs/module-graph.zh.md | 713 ++++-------------- packages/api/gateway/package.json | 12 +- packages/api/remotes/package.json | 32 +- packages/client/connection/package.json | 17 +- packages/client/hmr/package.json | 4 - packages/client/locale/package.json | 14 +- packages/client/modules/package.json | 5 +- packages/client/store/package.json | 3 +- packages/client/ui-agent-preset/package.json | 14 +- packages/client/ui-approval/package.json | 12 +- packages/client/ui-attachment/package.json | 8 +- .../client/ui-brand-official/package.json | 3 - packages/client/ui-chat/package.json | 30 +- packages/client/ui-commands/package.json | 12 +- packages/client/ui-conversation/package.json | 31 +- packages/client/ui-deliverables/package.json | 17 +- .../ui-directory-picker-browse/package.json | 5 - .../ui-directory-picker-native/package.json | 3 - packages/client/ui-goal/package.json | 14 +- packages/client/ui-input-trigger/package.json | 10 +- packages/client/ui-jobs/package.json | 8 +- packages/client/ui-layout/package.json | 5 - .../client/ui-message-feedback/package.json | 13 +- .../client/ui-model-selection/package.json | 13 +- .../client/ui-permission-presets/package.json | 12 +- packages/client/ui-plan/package.json | 10 +- packages/client/ui-primitives/package.json | 1 - packages/client/ui-reference/package.json | 10 - packages/client/ui-renderer/package.json | 1 - packages/client/ui-schedule/package.json | 7 - packages/client/ui-session/package.json | 6 +- .../client/ui-settings-general/package.json | 15 +- .../client/ui-settings-models/package.json | 7 +- .../ui-settings-plugin-inventory/package.json | 7 +- .../client/ui-settings-plugins/package.json | 7 +- packages/client/ui-settings/package.json | 11 +- packages/client/ui-sidebar/package.json | 9 +- packages/client/ui-skill/package.json | 9 - packages/client/ui-slots/package.json | 1 - packages/client/ui-subagent/package.json | 13 +- packages/client/ui-theme/package.json | 14 +- packages/client/ui-tool/package.json | 12 +- packages/client/ui-trajectory/package.json | 14 +- .../client/ui-user-questions/package.json | 12 +- packages/client/ui-workflow-run/package.json | 10 - packages/client/ui-workspace/package.json | 16 +- packages/client/web/package.json | 1 - packages/core/session/package.json | 8 +- .../cordis-client-runner/package.json | 6 - packages/extensions/ui-cordis/package.json | 10 - packages/llm/llm/package.json | 8 +- .../session-log-export/package.json | 49 +- packages/typert/registry/package.json | 5 +- pnpm-lock.yaml | 102 +-- 56 files changed, 459 insertions(+), 1645 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7112bfb506..ed60bccd07 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: 7c4ccbf6841070d37116641c1404a70cbb598769 -module-graph.zh.md: 4581a5bdda1e75a9678a731ab109d3b3c71b7434 +module-graph.md: e5225db8618a3d056257bf96e57e3ad99fb73134 +module-graph.zh.md: 9a5f72ef3ddcc8c3823dd59812869611dec77169 diff --git a/docs/module-graph.md b/docs/module-graph.md index 7c4ccbf684..e5225db861 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1,9 +1,9 @@ -# Module dependency graph +# Shared-instance dependency graph -Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package's `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped. +Peer dependencies among the `@deepseek-ai/dsh-*` harness packages. A peer means the consumer requires a shared instance; ordinary runtime dependencies and development-only relationships are not shown. The graph is grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` has package `b` as a peer. Names omit the `@deepseek-ai/dsh-` prefix. ```mermaid flowchart TD @@ -375,11 +375,6 @@ flowchart TD pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants pkg_sdk_minimal --> 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 pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants @@ -396,18 +391,37 @@ flowchart TD pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants + pkg_typert_loader --> pkg_invariants + pkg_typert_loader --> pkg_typert_registry pkg_typert_protocol --> pkg_invariants - pkg_typert_registry --> pkg_invariants + pkg_llm --> pkg_typert_protocol + pkg_session --> pkg_scope + pkg_api_gateway --> pkg_typert_protocol + pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand pkg_attachment --> pkg_invariants - pkg_client_modules --> pkg_host_webserver - pkg_client_modules --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_subprocess_e2b --> pkg_e2b pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout + pkg_experimental_inspector --> pkg_client_modules + pkg_experimental_inspector --> pkg_host_webserver + pkg_experimental_inspector --> pkg_invariants + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> 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_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol @@ -423,32 +437,6 @@ flowchart TD pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout - pkg_typert_loader --> pkg_invariants - pkg_typert_loader --> pkg_typert_registry - pkg_llm --> pkg_attachment - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout - pkg_llm --> pkg_typert_protocol - pkg_attachment_local --> pkg_attachment - pkg_attachment_local --> pkg_home_paths - pkg_attachment_local --> pkg_invariants - pkg_client_hmr --> pkg_client_modules - pkg_client_hmr --> pkg_host_webserver - pkg_client_hmr --> pkg_invariants - pkg_credentials_local --> pkg_atomic_write - pkg_credentials_local --> pkg_credentials - pkg_credentials_local --> pkg_home_paths - pkg_credentials_local --> pkg_invariants - pkg_credentials_local --> pkg_launch_environment - pkg_experimental_inspector --> pkg_client_modules - pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_inspector --> pkg_invariants - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_typert_protocol pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope @@ -457,37 +445,28 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_authorization --> pkg_credentials - pkg_authorization --> pkg_invariants - pkg_authorization --> pkg_llm - pkg_lsp --> pkg_brand - pkg_lsp --> pkg_invariants - pkg_lsp --> pkg_llm - pkg_skill_badge --> pkg_invariants - pkg_skill_badge --> pkg_skill - pkg_web_fetch_http --> pkg_invariants - pkg_web_fetch_http --> pkg_timeout - pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_invariants - pkg_web_search_exa --> pkg_launch_environment - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_invariants - pkg_web_search_perplexity --> pkg_launch_environment - pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_invariants pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_invariants - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_home_paths + pkg_attachment_local --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_code_runtime pkg_code_runtime_worker_thread --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_session pkg_code_runtime_worker_thread --> pkg_timeout - pkg_persona --> pkg_invariants - pkg_persona --> pkg_system_prompt + pkg_authorization --> pkg_credentials + pkg_authorization --> pkg_invariants + pkg_authorization --> pkg_llm + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_home_paths + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_launch_environment + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_invariants + pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -516,8 +495,23 @@ flowchart TD pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill + pkg_web_fetch_http --> pkg_invariants + pkg_web_fetch_http --> pkg_timeout + pkg_web_fetch_http --> pkg_web + pkg_web_search_exa --> pkg_invariants + pkg_web_search_exa --> pkg_launch_environment + pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_invariants + pkg_web_search_perplexity --> pkg_launch_environment + pkg_web_search_perplexity --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_invariants + pkg_app_boot --> pkg_launch_environment + pkg_app_boot --> pkg_system_prompt pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -525,6 +519,8 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol + pkg_persona --> pkg_invariants + pkg_persona --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -618,6 +614,14 @@ flowchart TD pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent @@ -1032,17 +1036,6 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_brand - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_directory_picker - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings - pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1121,11 +1114,6 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1167,13 +1155,6 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_invariants @@ -1261,14 +1242,6 @@ flowchart TD pkg_api_session_controller --> pkg_util_time pkg_api_session_controller --> pkg_util_workspace_path 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_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants - pkg_api_workspace_controller --> pkg_session - pkg_api_workspace_controller --> pkg_storage_domain - pkg_api_workspace_controller --> pkg_typert_protocol - pkg_api_workspace_controller --> pkg_workspace pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1305,30 +1278,18 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_remotes --> pkg_agent_presets - pkg_api_remotes --> pkg_api_gateway - pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller - pkg_api_remotes --> pkg_api_workspace_controller - pkg_api_remotes --> pkg_commands - pkg_api_remotes --> pkg_cordis_host_runner - pkg_api_remotes --> pkg_credentials - pkg_api_remotes --> pkg_file_reference - pkg_api_remotes --> pkg_goal - pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants - pkg_api_remotes --> pkg_llm - pkg_api_remotes --> pkg_message_feedback - pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_reference - pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_subagent - pkg_api_remotes --> pkg_user_approval - pkg_api_remotes --> pkg_user_questions - 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_experimental_client_ui_agent_team --> pkg_api_remotes + pkg_experimental_client_ui_agent_team --> pkg_api_session_controller + pkg_experimental_client_ui_agent_team --> pkg_client_locale + pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation + pkg_experimental_client_ui_agent_team --> pkg_client_ui_primitives + pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer + pkg_experimental_client_ui_agent_team --> pkg_client_ui_session + pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots + pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team + pkg_experimental_client_ui_agent_team --> pkg_invariants + pkg_experimental_client_ui_agent_team --> pkg_session + pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1348,358 +1309,6 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_client_ui_settings --> pkg_api_remotes - 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_ui_renderer - pkg_client_locale --> pkg_client_ui_settings - pkg_client_locale --> pkg_invariants - pkg_client_locale --> pkg_settings - pkg_client_ui_settings_models --> pkg_api_remotes - pkg_client_ui_settings_models --> pkg_client_locale - 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_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_locale - 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_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_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 - pkg_client_ui_layout --> pkg_invariants - pkg_cordis_client_runner --> pkg_api_remotes - 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_util_workspace_path - 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_remotes - 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_schedule - pkg_client_ui_workspace --> pkg_session - pkg_client_ui_workspace --> pkg_typert_protocol - pkg_client_ui_workspace --> pkg_util_workspace_path - pkg_client_ui_agent_preset --> pkg_agent_presets - 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_renderer - pkg_client_ui_brand_official --> pkg_client_ui_sidebar - pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_api_remotes - 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_schedule --> pkg_api_session_controller - pkg_client_ui_schedule --> pkg_client_locale - pkg_client_ui_schedule --> pkg_client_ui_conversation - pkg_client_ui_schedule --> pkg_client_ui_renderer - pkg_client_ui_schedule --> pkg_client_ui_session - pkg_client_ui_schedule --> pkg_invariants - pkg_client_ui_schedule --> pkg_schedule - 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_attachment - 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_llm - 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_experimental_client_ui_agent_team --> pkg_api_remotes - pkg_experimental_client_ui_agent_team --> pkg_api_session_controller - pkg_experimental_client_ui_agent_team --> pkg_client_locale - pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation - pkg_experimental_client_ui_agent_team --> pkg_client_ui_primitives - pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer - pkg_experimental_client_ui_agent_team --> pkg_client_ui_session - pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots - pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team - pkg_experimental_client_ui_agent_team --> pkg_invariants - pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol - 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_settings - 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_settings - pkg_client_ui_chat --> pkg_token_meter - pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_workspace_path - 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_api_session_controller - pkg_client_ui_reference --> pkg_client_connection - pkg_client_ui_reference --> pkg_client_locale - 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_client_ui_reference --> pkg_util_workspace_path - 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_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_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_attachment - pkg_session_log_export --> pkg_client_connection - 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_session_log_export --> pkg_session - pkg_session_log_export --> pkg_session_persistence - pkg_session_log_export --> pkg_session_query - 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_client_ui_trajectory - pkg_client_ui_attachment --> pkg_invariants - pkg_client_ui_deliverables --> pkg_api_remotes - 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_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_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_tool --> pkg_util_workspace_path - 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 @@ -1715,30 +1324,66 @@ flowchart TD pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent pkg_client_test_runtime --> pkg_typert_protocol - 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 + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess ``` -| Package | Group | Depends on | +| Package | Group | Peer dependencies | | --- | --- | --- | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | — | +| [`client-connection`](../packages/client/connection) | `client` | — | +| [`client-hmr`](../packages/client/hmr) | `client` | — | +| [`client-locale`](../packages/client/locale) | `client` | — | +| [`client-modules`](../packages/client/modules) | `client` | — | +| [`client-store`](../packages/client/store) | `client` | — | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | +| [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | — | +| [`client-ui-commands`](../packages/client/ui-commands) | `client` | — | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | — | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | +| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | +| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | — | +| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | +| [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-session`](../packages/client/ui-session) | `client` | — | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | +| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | +| [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | — | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | — | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | — | +| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | — | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | +| [`client-web`](../packages/client/web) | `client` | — | +| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | +| [`typert-registry`](../packages/typert/registry) | `typert` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1756,11 +1401,6 @@ flowchart TD | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `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) | | [`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) | @@ -1777,38 +1417,34 @@ flowchart TD | [`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-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`typert-protocol`](../packages/typert/protocol) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`llm`](../packages/llm/llm) | `llm` | [`typert-protocol`](../packages/typert/protocol) | +| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`typert-protocol`](../packages/typert/protocol) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`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) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | -| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | -| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | +| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`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) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1817,8 +1453,14 @@ flowchart TD | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | +| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1837,6 +1479,7 @@ flowchart TD | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1908,7 +1551,6 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1917,12 +1559,9 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`typert-protocol`](../packages/typert/protocol) | | [`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-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1933,57 +1572,15 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`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-settings-controller`](../packages/api/settings-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), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`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) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`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) | | [`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), [`attachment`](../packages/attachment/attachment), [`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) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`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-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-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-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-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), [`util-workspace-path`](../packages/util/workspace-path), [`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-remotes`](../packages/api/remotes), [`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), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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-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` | [`api-remotes`](../packages/api/remotes), [`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-schedule`](../packages/client/ui-schedule) | `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), [`schedule`](../packages/schedule/schedule) | -| [`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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`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-settings`](../packages/client/ui-settings), [`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), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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` | [`attachment`](../packages/attachment/attachment), [`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-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), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`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), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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), [`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-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-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), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`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) | +| [`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 4581a5bdda..9a5f72ef3d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1,11 +1,11 @@ - + -# 模块依赖关系图 +# 共享实例依赖关系图 [English](module-graph.md) | 中文 -`@deepseek-ai/dsh-*` harness 包之间的依赖关系。该关系图根据各包的 `peerDependencies`(规范的运行时依赖信号)生成,并按 `packages//` 层级分组。边 `a --> b` 表示包 `a` 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。 +`@deepseek-ai/dsh-*` harness 包之间的 peer 依赖关系。peer 表示消费端需要提供共享实例,不包括普通运行时 dependency 或仅开发期关系。该图按 `packages//` 层级分组;边 `a --> b` 表示包 `a` peer 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。 ```mermaid flowchart TD @@ -377,11 +377,6 @@ flowchart TD pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants pkg_sdk_minimal --> 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 pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants @@ -398,18 +393,37 @@ flowchart TD pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants + pkg_typert_loader --> pkg_invariants + pkg_typert_loader --> pkg_typert_registry pkg_typert_protocol --> pkg_invariants - pkg_typert_registry --> pkg_invariants + pkg_llm --> pkg_typert_protocol + pkg_session --> pkg_scope + pkg_api_gateway --> pkg_typert_protocol + pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand pkg_attachment --> pkg_invariants - pkg_client_modules --> pkg_host_webserver - pkg_client_modules --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_subprocess_e2b --> pkg_e2b pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout + pkg_experimental_inspector --> pkg_client_modules + pkg_experimental_inspector --> pkg_host_webserver + pkg_experimental_inspector --> pkg_invariants + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> 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_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol @@ -425,32 +439,6 @@ flowchart TD pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout - pkg_typert_loader --> pkg_invariants - pkg_typert_loader --> pkg_typert_registry - pkg_llm --> pkg_attachment - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout - pkg_llm --> pkg_typert_protocol - pkg_attachment_local --> pkg_attachment - pkg_attachment_local --> pkg_home_paths - pkg_attachment_local --> pkg_invariants - pkg_client_hmr --> pkg_client_modules - pkg_client_hmr --> pkg_host_webserver - pkg_client_hmr --> pkg_invariants - pkg_credentials_local --> pkg_atomic_write - pkg_credentials_local --> pkg_credentials - pkg_credentials_local --> pkg_home_paths - pkg_credentials_local --> pkg_invariants - pkg_credentials_local --> pkg_launch_environment - pkg_experimental_inspector --> pkg_client_modules - pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_inspector --> pkg_invariants - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_typert_protocol pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope @@ -459,37 +447,28 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_authorization --> pkg_credentials - pkg_authorization --> pkg_invariants - pkg_authorization --> pkg_llm - pkg_lsp --> pkg_brand - pkg_lsp --> pkg_invariants - pkg_lsp --> pkg_llm - pkg_skill_badge --> pkg_invariants - pkg_skill_badge --> pkg_skill - pkg_web_fetch_http --> pkg_invariants - pkg_web_fetch_http --> pkg_timeout - pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_invariants - pkg_web_search_exa --> pkg_launch_environment - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_invariants - pkg_web_search_perplexity --> pkg_launch_environment - pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_invariants pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_invariants - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_home_paths + pkg_attachment_local --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_code_runtime pkg_code_runtime_worker_thread --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_session pkg_code_runtime_worker_thread --> pkg_timeout - pkg_persona --> pkg_invariants - pkg_persona --> pkg_system_prompt + pkg_authorization --> pkg_credentials + pkg_authorization --> pkg_invariants + pkg_authorization --> pkg_llm + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_home_paths + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_launch_environment + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_invariants + pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -518,8 +497,23 @@ flowchart TD pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill + pkg_web_fetch_http --> pkg_invariants + pkg_web_fetch_http --> pkg_timeout + pkg_web_fetch_http --> pkg_web + pkg_web_search_exa --> pkg_invariants + pkg_web_search_exa --> pkg_launch_environment + pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_invariants + pkg_web_search_perplexity --> pkg_launch_environment + pkg_web_search_perplexity --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_invariants + pkg_app_boot --> pkg_launch_environment + pkg_app_boot --> pkg_system_prompt pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -527,6 +521,8 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol + pkg_persona --> pkg_invariants + pkg_persona --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -620,6 +616,14 @@ flowchart TD pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent @@ -1034,17 +1038,6 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_brand - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_directory_picker - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings - pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1123,11 +1116,6 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1169,13 +1157,6 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_invariants @@ -1263,14 +1244,6 @@ flowchart TD pkg_api_session_controller --> pkg_util_time pkg_api_session_controller --> pkg_util_workspace_path 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_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants - pkg_api_workspace_controller --> pkg_session - pkg_api_workspace_controller --> pkg_storage_domain - pkg_api_workspace_controller --> pkg_typert_protocol - pkg_api_workspace_controller --> pkg_workspace pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1307,30 +1280,18 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_remotes --> pkg_agent_presets - pkg_api_remotes --> pkg_api_gateway - pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller - pkg_api_remotes --> pkg_api_workspace_controller - pkg_api_remotes --> pkg_commands - pkg_api_remotes --> pkg_cordis_host_runner - pkg_api_remotes --> pkg_credentials - pkg_api_remotes --> pkg_file_reference - pkg_api_remotes --> pkg_goal - pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants - pkg_api_remotes --> pkg_llm - pkg_api_remotes --> pkg_message_feedback - pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_reference - pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_subagent - pkg_api_remotes --> pkg_user_approval - pkg_api_remotes --> pkg_user_questions - 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_experimental_client_ui_agent_team --> pkg_api_remotes + pkg_experimental_client_ui_agent_team --> pkg_api_session_controller + pkg_experimental_client_ui_agent_team --> pkg_client_locale + pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation + pkg_experimental_client_ui_agent_team --> pkg_client_ui_primitives + pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer + pkg_experimental_client_ui_agent_team --> pkg_client_ui_session + pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots + pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team + pkg_experimental_client_ui_agent_team --> pkg_invariants + pkg_experimental_client_ui_agent_team --> pkg_session + pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1350,358 +1311,6 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_client_ui_settings --> pkg_api_remotes - 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_ui_renderer - pkg_client_locale --> pkg_client_ui_settings - pkg_client_locale --> pkg_invariants - pkg_client_locale --> pkg_settings - pkg_client_ui_settings_models --> pkg_api_remotes - pkg_client_ui_settings_models --> pkg_client_locale - 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_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_locale - 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_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_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 - pkg_client_ui_layout --> pkg_invariants - pkg_cordis_client_runner --> pkg_api_remotes - 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_util_workspace_path - 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_remotes - 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_schedule - pkg_client_ui_workspace --> pkg_session - pkg_client_ui_workspace --> pkg_typert_protocol - pkg_client_ui_workspace --> pkg_util_workspace_path - pkg_client_ui_agent_preset --> pkg_agent_presets - 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_renderer - pkg_client_ui_brand_official --> pkg_client_ui_sidebar - pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_api_remotes - 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_schedule --> pkg_api_session_controller - pkg_client_ui_schedule --> pkg_client_locale - pkg_client_ui_schedule --> pkg_client_ui_conversation - pkg_client_ui_schedule --> pkg_client_ui_renderer - pkg_client_ui_schedule --> pkg_client_ui_session - pkg_client_ui_schedule --> pkg_invariants - pkg_client_ui_schedule --> pkg_schedule - 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_attachment - 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_llm - 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_experimental_client_ui_agent_team --> pkg_api_remotes - pkg_experimental_client_ui_agent_team --> pkg_api_session_controller - pkg_experimental_client_ui_agent_team --> pkg_client_locale - pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation - pkg_experimental_client_ui_agent_team --> pkg_client_ui_primitives - pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer - pkg_experimental_client_ui_agent_team --> pkg_client_ui_session - pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots - pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team - pkg_experimental_client_ui_agent_team --> pkg_invariants - pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol - 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_settings - 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_settings - pkg_client_ui_chat --> pkg_token_meter - pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_workspace_path - 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_api_session_controller - pkg_client_ui_reference --> pkg_client_connection - pkg_client_ui_reference --> pkg_client_locale - 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_client_ui_reference --> pkg_util_workspace_path - 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_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_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_attachment - pkg_session_log_export --> pkg_client_connection - 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_session_log_export --> pkg_session - pkg_session_log_export --> pkg_session_persistence - pkg_session_log_export --> pkg_session_query - 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_client_ui_trajectory - pkg_client_ui_attachment --> pkg_invariants - pkg_client_ui_deliverables --> pkg_api_remotes - 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_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_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_tool --> pkg_util_workspace_path - 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 @@ -1717,30 +1326,66 @@ flowchart TD pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent pkg_client_test_runtime --> pkg_typert_protocol - 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 + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess ``` -| Package | Group | Depends on | +| 包 | 分组 | Peer 依赖 | | --- | --- | --- | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | — | +| [`client-connection`](../packages/client/connection) | `client` | — | +| [`client-hmr`](../packages/client/hmr) | `client` | — | +| [`client-locale`](../packages/client/locale) | `client` | — | +| [`client-modules`](../packages/client/modules) | `client` | — | +| [`client-store`](../packages/client/store) | `client` | — | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | +| [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | — | +| [`client-ui-commands`](../packages/client/ui-commands) | `client` | — | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | — | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | +| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | +| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | — | +| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | +| [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-session`](../packages/client/ui-session) | `client` | — | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | +| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | +| [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | — | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | — | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | — | +| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | — | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | +| [`client-web`](../packages/client/web) | `client` | — | +| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | — | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | +| [`typert-registry`](../packages/typert/registry) | `typert` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1758,11 +1403,6 @@ flowchart TD | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `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) | | [`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) | @@ -1779,38 +1419,34 @@ flowchart TD | [`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-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`typert-protocol`](../packages/typert/protocol) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`llm`](../packages/llm/llm) | `llm` | [`typert-protocol`](../packages/typert/protocol) | +| [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`typert-protocol`](../packages/typert/protocol) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`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) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | -| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | -| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`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) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | +| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`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) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1819,8 +1455,14 @@ flowchart TD | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | +| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1839,6 +1481,7 @@ flowchart TD | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1910,7 +1553,6 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1919,12 +1561,9 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`typert-protocol`](../packages/typert/protocol) | | [`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-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1935,57 +1574,15 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`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), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`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-settings-controller`](../packages/api/settings-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), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`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) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`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) | | [`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), [`attachment`](../packages/attachment/attachment), [`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) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`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-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-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-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-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), [`util-workspace-path`](../packages/util/workspace-path), [`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-remotes`](../packages/api/remotes), [`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), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`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-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` | [`api-remotes`](../packages/api/remotes), [`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-schedule`](../packages/client/ui-schedule) | `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), [`schedule`](../packages/schedule/schedule) | -| [`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), [`attachment`](../packages/attachment/attachment), [`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), [`llm`](../packages/llm/llm), [`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) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`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-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`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-settings`](../packages/client/ui-settings), [`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), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`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), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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` | [`attachment`](../packages/attachment/attachment), [`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-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), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`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), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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), [`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-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-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), [`util-workspace-path`](../packages/util/workspace-path) | -| [`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), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`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) | +| [`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/api/gateway/package.json b/packages/api/gateway/package.json index d5601ab188..5d66b7818d 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -57,17 +57,12 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "peerDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -78,6 +73,7 @@ "@deepseek-ai/dsh-util-crypto": "workspace:^", "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:^", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" } } diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a271fe29a..40e6e8199c 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -55,37 +55,17 @@ "lib/types/**/*.d.ts" ], "dependencies": { - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-settings-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^" + "@deepseek-ai/dsh-scope": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -97,11 +77,13 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^" + "@deepseek-ai/dsh-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^" } } diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index f19f30e40d..0a683d4d15 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -39,7 +39,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-credentials": "workspace:^" }, "files": [ "lib/index.js", @@ -48,24 +49,12 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 35f1beed3c..74bb43f428 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -41,10 +41,6 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 466734cc9b..da1337c42e 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -43,13 +43,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "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:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -63,11 +57,11 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-client-connection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 1e608f4c58..9ae338e0eb 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -54,9 +54,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/store/package.json b/packages/client/store/package.json index e6b8eb47e6..ba0cae3b47 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -31,8 +31,7 @@ "zustand": "~4.4.7" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 7ec49fd96f..cbc072bb2a 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -50,19 +50,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@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-workspace": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 060cb0f386..a6fafca529 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -48,17 +48,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 68efaafcd5..42d140be37 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -70,12 +70,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 791ce76c9c..03d00017cb 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -44,9 +44,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 32018de464..f8e7336a26 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -51,31 +51,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-approval": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@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-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -102,7 +78,6 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", @@ -112,7 +87,8 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index a5a15451f2..1e9f5cd3cc 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -49,17 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e9e434071b..bd46b7dba7 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -55,35 +55,11 @@ "@lexical/plain-text": "^0.49.0", "@lexical/text": "^0.49.0", "@lexical/utils": "^0.49.0", - "lexical": "^0.49.0" + "lexical": "^0.49.0", + "@deepseek-ai/dsh-settings": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@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:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -110,7 +86,6 @@ "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index a977a5e609..b22bbf6109 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -48,16 +48,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "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-invariants": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -70,7 +61,6 @@ "@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", @@ -82,5 +72,8 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "@deepseek-ai/dsh-system-prompt": "workspace:^" + } } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 90cd0ce667..4b82d5df9a 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -49,11 +49,6 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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": { diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 9bb352e7cb..24dae2f8cb 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -44,9 +44,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 1bd3457b73..2cb4a5f548 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -49,19 +49,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "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:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 6114813aa3..16d607bae9 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -49,15 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "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-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index e944070fe6..abc3fc54ea 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -45,13 +45,7 @@ "access": "public" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "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:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index ea634dbafa..7b43585188 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -46,11 +46,6 @@ }, "license": "MIT", "peerDependencies": { - "@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-theme": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 51ca031762..2671324018 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -46,18 +46,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index e368cfb6a9..d383664da4 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -46,18 +46,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 3531416e93..fbe10f6133 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -47,17 +47,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 97cd4f4ca4..7831563a6d 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -45,15 +45,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 8041b7dbf3..a6fba6ba69 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -61,7 +61,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index b0b05a0aa1..741e6e100c 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -47,16 +47,6 @@ }, "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-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 997ac47de6..fbf258b55c 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -44,7 +44,6 @@ "use-sync-external-store": "1.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 2abd671289..31d143d7dd 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -45,13 +45,6 @@ "access": "public" }, "peerDependencies": { - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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-schedule": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 7ab9428e32..da39a4f1bf 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -44,11 +44,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 3f4de05dd4..ea04c835fc 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -48,19 +48,11 @@ "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "workspace:^", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/dsh-settings": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -76,7 +68,6 @@ "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 8c65aacc8f..7ebdd1f89a 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -45,12 +45,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 1049448e57..53566b7eea 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -45,12 +45,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 73e7d06cbd..581bf4ecfa 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -45,12 +45,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 02f6a598bb..08290d8fb0 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -42,14 +42,8 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -60,7 +54,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 783a743df5..b74af2dac0 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -51,14 +51,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@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:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d6e679509b..78a92c7cb1 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -48,15 +48,6 @@ }, "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-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": { diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 0e317a33ee..b65d871723 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -38,7 +38,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 75afb63a17..e731e9c422 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -47,18 +47,7 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "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/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:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index ee343d5a89..72fac3548b 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -44,15 +44,7 @@ }, "license": "MIT", "peerDependencies": { - "@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-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -67,7 +59,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, @@ -84,6 +75,7 @@ }, "dependencies": { "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^" } } diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index ca96120343..3a26243df1 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -49,17 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@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-ui-conversation": "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:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 242810c753..6ebd62a003 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -51,19 +51,7 @@ "diff": "^9.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index b8059eecfb..2edeb93223 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -51,17 +51,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-invariants": "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-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index bc5273310b..e11e49b338 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -54,16 +54,6 @@ ], "license": "MIT", "peerDependencies": { - "@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-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tool-workflow": "workspace:^", - "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 142ed0f2bd..07dd0260e6 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -54,21 +54,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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-sidebar": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-schedule": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 6d663b1056..bf03741764 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -42,7 +42,6 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "files": [ diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 76062c71a3..7a75641d7c 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -45,20 +45,18 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-llm": "workspace:^" } } diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index a7c39b524e..cac1ac0781 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -46,12 +46,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index f37b0a8dc5..4a62ad38d8 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -51,16 +51,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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:^" }, "devDependencies": { diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 64c2eb0123..1c3e4c7196 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -57,23 +57,19 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-timeout": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index f4a124c78b..bb6fdd8773 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -2,7 +2,9 @@ "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", "version": "0.1.2-alpha.1", - "publishConfig": { "access": "public" }, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", @@ -12,33 +14,39 @@ "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" }, + ".": { + "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" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"], - "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "workspace:^", - "fflate": "^0.8.2" + "fflate": "^0.8.2", + "@deepseek-ai/dsh-session": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "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:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -56,7 +64,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index a567280cf7..e3fc642a2e 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -53,15 +53,14 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f790a3c1a9..60ea772446 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,9 +627,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -652,6 +649,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry @@ -667,12 +667,12 @@ importers: packages/api/remotes: dependencies: - '@deepseek-ai/dsh-scope': + '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-typert-protocol': + version: link:../session-controller + '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../../typert/protocol + version: link:../../core/session devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -683,15 +683,15 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../session-controller '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../settings-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -719,9 +719,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback - '@deepseek-ai/dsh-session': + '@deepseek-ai/dsh-scope': specifier: workspace:^ - version: link:../../core/session + version: link:../../core/scope '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference @@ -731,6 +731,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval @@ -1663,6 +1666,9 @@ importers: packages/client/connection: dependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -1682,9 +1688,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-credentials': - specifier: workspace:^ - version: link:../../credentials/credentials '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../../host/directory-picker @@ -1731,6 +1734,9 @@ importers: packages/client/locale: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -1765,9 +1771,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -1992,6 +1995,9 @@ importers: packages/client/ui-chat: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2068,9 +2074,6 @@ importers: '@deepseek-ai/dsh-session-stats': specifier: workspace:^ version: link:../../session/session-stats - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -2156,6 +2159,9 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2250,9 +2256,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -2288,6 +2291,10 @@ importers: version: 4.4.3 packages/client/ui-deliverables: + dependencies: + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2328,9 +2335,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3058,10 +3062,6 @@ importers: version: 18.3.1 packages/client/ui-settings: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3084,6 +3084,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3093,6 +3096,9 @@ importers: packages/client/ui-settings-general: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -3139,9 +3145,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3450,6 +3453,9 @@ importers: packages/client/ui-theme: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -3493,9 +3499,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4422,6 +4425,10 @@ importers: version: link:../../runtime-diagnostics/invariants packages/core/session: + dependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4432,9 +4439,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope @@ -6369,6 +6373,9 @@ importers: packages/llm/llm: dependencies: + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto @@ -6391,9 +6398,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -7162,6 +7166,9 @@ importers: packages/session-query/session-log-export: dependencies: + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7214,9 +7221,6 @@ 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-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence @@ -9645,9 +9649,6 @@ importers: packages/typert/registry: dependencies: - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../protocol zod: specifier: ^4.4.3 version: 4.4.3 @@ -9658,6 +9659,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../protocol packages/util/atomic-write: devDependencies: From 1d81fc7540dd02fe164fbdf7b160b7a319d63ec4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 14:33:33 +0800 Subject: [PATCH 080/429] docs(testing): give the review checklist its own test-reliability entry The 'Test strength' bullet carried assertion strength, external-state verification, the reliability reference, and the coverage caveat at once. Splitting the reference into an adjacent entry matches how the orientation list above names the same skill, and restores 'Test strength' to one subject. The new entry also names the platform and timeout-budget rules the skill now carries, so the checklist covers what a reviewer is being pointed at. --- .agents/skills/dsh-code-review/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index a18ac20d4e..d4dd0b491a 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -40,7 +40,8 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Borrowed and derived state:** determine whether each retained value is borrowed or owned under the package contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. - **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. - **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch invalid Loader exports; a function plugin must named-export its namespace and have no default export. -- **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. For resource-owning, asynchronous, or flaky tests, apply [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) to the real worker/job topology, allocation, restoration, synchronization, and quiescent teardown. Coverage is necessary but not evidence that the scenario is correct. +- **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. +- **Test reliability:** for a resource-owning, asynchronous, platform-sensitive, or flaky test, apply [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) to the real worker/job topology, resource allocation, global-state restoration, synchronization, timeout budget, and quiescent teardown. - **Invariant lifecycle and negative controls:** verify candidate observations are rejected before publication where possible, session-backed checks reconstruct durable history after late loading or HMR, and a deliberately invalid case fails through the real runner for the intended rule. - **Implemented Agent Notes match shipped reality:** when a PR implements a proposed Agent Note, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. - **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review expected-output diffs as behavior changes, not formatting noise. From cc5173f4cfc634559c5b3349be68da714dcc7f84 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 15:31:12 +0800 Subject: [PATCH 081/429] docs(testing): state the concurrent execution model where tests are written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill carries the reliability rules, but nothing an agent loads by default said that specs run concurrently at all. The testing policy described tiers and evidence without ever stating how a spec is executed, and neither subtree AGENTS.md mentioned it — including scripts/, where the two suites that recently failed on unrelated branches live. docs/testing.md gains the execution model as the one home for the fact: forked workers, concurrent coverage partitions beside other gates, and self-hosted runners sharing a host and volume, with the rule that a spec passing only when run alone is a defect in the spec. It links the skill for the detailed rules. packages/AGENTS.md and scripts/AGENTS.md carry the short actionable form and link that section, so the rule is present in the context loaded while a test in either subtree is being written. Both ceilings are raised for the added words and the targets in docs/AGENTS.md move with them: docs/testing.md 1150 to 1300 (now 1237) and packages/AGENTS.md 675 to 750 (now 712), each keeping the 5% headroom the standard requires. --- docs/AGENTS.md | 2 +- docs/testing.i18n.yaml | 4 ++-- docs/testing.md | 4 ++++ docs/testing.zh.md | 4 ++++ packages/AGENTS.md | 1 + scripts/AGENTS.md | 2 ++ scripts/doc-budgets.manifest.json | 4 ++-- 7 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 5efb5c6c40..17d1613e3c 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -54,7 +54,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 675 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,150, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index e25a35536c..6264efd8b6 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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/testing.md -testing.md: c21c67bba85387e35e80d16217c2673653281672 -testing.zh.md: 008bee58918510c8afb695a7e0b880828d698227 +testing.md: 25514702e7aa8649c10ec213f5a6bf5c6e9e9a09 +testing.zh.md: 5338e4bc392e1e1ff16c32f708970be917e6efa7 diff --git a/docs/testing.md b/docs/testing.md index c21c67bba8..25514702e7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,10 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning Session fixtures keep headers and payloads but omit body sequence/time envelopes. Replay synthesizes them. Fixtures use canonical packed rows; [the migrator](../scripts/migrate-packed-session-fixtures.ts) rewrites old layouts. +## How specs execute + +Forked workers run several spec files at once, the coverage gate splits into concurrent partitions beside the other gates in its job, and the self-hosted runners share one host and one volume. Only the process is isolated: ports, predictable paths, external namespaces, and inherited children are not. Own each acquired resource through its teardown, and read a spec that passes only when it runs alone as a defect in the spec rather than an unstable runner. [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the allocation, restoration, synchronization, timeout-budget, platform, and teardown rules; its [flake diagnosis workflow](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md) classifies an existing probabilistic failure. + ## The with-key policy: inference is cheap here We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Cover file-writing prompts, multi-turn conversations, tool use, and mid-stream cancellation. Highest-value are **smoke tests** that boot a shipped `dsh` profile, send one prompt, and check the world — they catch the "green unit tests, broken product" class that mocks cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). Self-skip keeps secretless CI and keyless contributors unblocked; it is not a cost signal. Profile-level integration tests live under `apps/cli/tests/profiles/`; package-specific compositions stay with their package tests. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 008bee5891..5338e4bc39 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -15,6 +15,10 @@ 会话 fixture 保留 header 与 payload,但省略正文序号/时间 envelope。回放会合成这些字段;运行时持久化不变。fixture 使用规范打包行;[迁移器](../scripts/migrate-packed-session-fixtures.ts)会改写旧布局。 +## spec 如何被执行 + +fork 出的 worker 会同时运行多个 spec 文件,coverage gate 会拆成并发的 partition,与同一个 job 中的其它 gate 并排运行,而自托管 runner 共用同一台宿主机和同一个卷。被隔离的只有进程:端口、可预测路径、外部命名空间和继承而来的子进程都不隔离。为每个占用的资源负责到它的 teardown,并把「只有单独运行时才通过」的 spec 读作该 spec 的缺陷,而不是 runner 不稳定。[dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) 负责资源分配、状态恢复、同步、超时预算、平台差异与 teardown 规则;它的 [flake 诊断流程](../.agents/skills/dsh-ci-test-reliability/references/ci-flake-diagnosis.md)用于归类已经存在的概率性失败。 + ## 带密钥策略:推理(inference)在这里很便宜 我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、包含多个轮次的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动已交付的 `dsh` profile、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.zh.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。Profile 级集成测试位于 `apps/cli/tests/profiles/`;包专属组合留在对应包的测试目录中。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index af47b16740..1385f129ff 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,6 +15,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. +- **Specs run concurrently** in forked workers beside other gate processes. Own each acquired port, path, and child process through teardown; a spec that passes only when run alone is a defect in the spec ([execution model](../docs/testing.md#how-specs-execute)). - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 8a50148e37..d7ec185e34 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,5 @@ # 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. 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. + +Script specs run in forked workers beside the rest of the suite and beside the other gate processes in their job, so own every port, temporary path, and child process a spec acquires. A spec that passes only when it runs alone is a defect in the spec; [the testing policy](../docs/testing.md#how-specs-execute) states the execution model and [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the rules. diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 1f7f25cb2a..2017bb43ca 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,7 +4,7 @@ "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1150, - "packages/AGENTS.md": 675, + "docs/testing.md": 1300, + "packages/AGENTS.md": 750, "packages/README.md": 994 } From d5a9e5b274590a763a498d8008f6a1d94e428914 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 16:52:39 +0800 Subject: [PATCH 082/429] fix(web): publish the drill claim before the descent edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pointer drill in the @ menu left no breadcrumb and a crumb click dropped the header, while the keyboard drill worked. InputTriggerController.settle assigned `drilled` after execute() returned, but a pointer mousedown runs outside any Lexical update, so the descent edit commits discretely and re-enters track() during execute() — where refreshHeaders and fetchCandidates both read the claim while it was still clear. settle now claims the drill before dispatching the edit and withdraws it only when the edit is refused, which mutates nothing and so drives no re-entrant track(). --- ...ll-claim-precedes-the-drill-edit.i18n.yaml | 6 +++ ...-29-drill-claim-precedes-the-drill-edit.md | 37 +++++++++++++++++++ ...-drill-claim-precedes-the-drill-edit.zh.md | 37 +++++++++++++++++++ apps/web/tests/reference-composer.e2e.ts | 28 +++++++++++++- .../ui-input-trigger/src/client/controller.ts | 17 +++++---- .../tests/service.client.spec.ts | 28 +++++++++++++- 6 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.i18n.yaml new file mode 100644 index 0000000000..0760aa642b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.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-29-drill-claim-precedes-the-drill-edit.md +2026-08-29-drill-claim-precedes-the-drill-edit.md: 35ca60360c2c8647c44ce9a164cff71fa112f942 +2026-08-29-drill-claim-precedes-the-drill-edit.zh.md: 58f7ca84d201726c0630f7cb6e9bf9614de76c20 diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.md b/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.md new file mode 100644 index 0000000000..35ca60360c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.md @@ -0,0 +1,37 @@ +# Agent Note: The drill claim is published before the edit that re-enters tracking + +Status: implemented + +English | [中文](2026-08-29-drill-claim-precedes-the-drill-edit.zh.md) + +## Problem + +A pointer descent in the `@` menu produced no breadcrumb, while the keyboard descent into the same directory produced one (#3310). Clicking a crumb — the gesture the breadcrumb exists for — dropped the header entirely instead of re-listing the step it named. Rows in a pointer-drilled listing also repeated the parent directory the header was supposed to carry. + +The three faults are one ordering defect in `InputTriggerController.settle`. The drill claim (`drilled`) was assigned after `execute()` returned, on the assumption that the input applies a descent edit and re-tracks later. That holds only for the keyboard: `KEY_TAB_COMMAND` handlers run inside a Lexical update, so `SessionInputShell.applyEdit` joins the enclosing update and the commit — with the `track()` call its update listener drives — lands after `settle` has returned. A pointer `mousedown` handler is outside any update, so `applyEdit` runs `editor.update(fn, { discrete: true })`, which sets `_flushSync` and commits synchronously; `track()` therefore re-enters the controller *during* `execute()`, and both readers of the claim — `refreshHeaders` and `fetchCandidates` — saw it still clear. Every existing test modeled the keyboard ordering: the fake insert listener returned `true` and the spec re-tracked afterwards by hand, so the pointer ordering was never exercised. + +## Decision + +`settle` claims the drill before dispatching the edit, and withdraws the claim only when the edit is refused: + +```ts ignore-check +this.reduce({ type: 'close' }) +this.drilled = action === 'drill' +if (!this.execute(outcome, hit.span)) this.drilled = false +``` + +The claim still follows `reduce({ type: 'close' })`, whose teardown clears it. Withdrawal remains exact because a refused edit mutates nothing and so drives no re-entrant `track()`: `insertText` fails its `draftRev` CAS before touching the editor, and `$replaceDetectSpanWithText` returns `false` from `selectSpan` ahead of `$setSelection`. The observable guarantee the [breadcrumb decision](../feature/2026-08-27-web-at-mention-discovery-and-row-content.md) states is unchanged — a header never names a directory nobody descended into — and both descent gestures now reach `header` and `candidates` as a drill. + +## Alternatives considered + +**Re-publish the header after `execute` returns.** Rejected: it treats the visible half of one defect. `fetchCandidates` reads the same claim, so the candidate request would still report `drilled: false` and `ui-reference` would keep repeating the parent directory on every row of a pointer-drilled listing. + +**Defer `execute` to a microtask so the re-entrant track always lands after `settle`.** Rejected: the edit carries `hit.span` for revision CAS, and postponing it past the current task lets an intervening keystroke invalidate the span, turning a working descent into a silently refused one. + +**Make `applyEdit` never flush synchronously.** Rejected: `discrete` is what keeps a programmatic edit and the detect coordinates computed from it in one task; relaxing it to fix a menu flag would loosen the whole input machine's ordering for every caller. + +## Consequences + +- Tab, the row chevron, and a crumb reach one behavior, so the breadcrumb no longer depends on which gesture opened the listing. +- Any future state a source reads through `header` or `candidates` must be published before `execute`, because the input can re-enter `track()` inside it. The claim is instance state on the controller, so the ordering is the only thing enforcing it. +- Coverage: a controller spec whose insert listener re-tracks synchronously — the pointer ordering — asserts both readers, and `reference-composer.e2e.ts` asserts the breadcrumb and the trimmed rows after a chevron drill and walks a two-level trail back through a crumb click. The keyboard ordering keeps its existing spec, so a regression that fixes one gesture by breaking the other fails. diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.zh.md new file mode 100644 index 0000000000..58f7ca84d2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.zh.md @@ -0,0 +1,37 @@ +# Agent Note: The drill claim is published before the edit that re-enters tracking + +Status: implemented + +[English](2026-08-29-drill-claim-precedes-the-drill-edit.md) | 中文 + +## Problem + +在 `@` 菜单里用指针进入目录不产生 breadcrumb,而用键盘进入同一个目录则会产生(#3310)。点击 crumb——breadcrumb 存在的意义所在——不但没有重新列出它所指的那一层,反而让整个 header 消失。指针进入的列表里,每一行还会重复 header 本应承担的父目录。 + +这三处故障是 `InputTriggerController.settle` 中的同一个顺序缺陷。drill 声明(`drilled`)过去在 `execute()` 返回之后才赋值,前提是输入层稍后才应用下钻编辑并重新 track。该前提只对键盘成立:`KEY_TAB_COMMAND` 的处理器运行在 Lexical update 内部,`SessionInputShell.applyEdit` 因此并入外层 update,提交——以及其 update listener 驱动的 `track()` 调用——落在 `settle` 返回之后。指针的 `mousedown` 处理器不在任何 update 内,`applyEdit` 于是执行 `editor.update(fn, { discrete: true })`,该选项置起 `_flushSync` 并同步提交;`track()` 因此在 `execute()` **执行期间**重入控制器,而声明的两个读取方——`refreshHeaders` 与 `fetchCandidates`——看到的仍是未置位的值。既有测试全部按键盘顺序建模:伪造的 insert 监听器只返回 `true`,由用例事后手工重新 track,指针顺序从未被覆盖。 + +## Decision + +`settle` 在派发编辑之前声明 drill,并且只在编辑被拒绝时撤回: + +```ts ignore-check +this.reduce({ type: 'close' }) +this.drilled = action === 'drill' +if (!this.execute(outcome, hit.span)) this.drilled = false +``` + +声明仍然排在 `reduce({ type: 'close' })` 之后,因为后者的清理会把它清掉。撤回依然精确,原因是被拒绝的编辑不做任何变更,因而不会驱动重入的 `track()`:`insertText` 在碰到编辑器之前就没通过 `draftRev` CAS,`$replaceDetectSpanWithText` 也在 `$setSelection` 之前就从 `selectSpan` 返回 `false`。[breadcrumb 决策](../feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md)所声明的可观察保证不变——header 绝不会指向一个无人进入过的目录——而两种下钻手势现在都以 drill 的身份抵达 `header` 与 `candidates`。 + +## Alternatives considered + +**在 `execute` 返回后重新发布 header。** 否决:这只处理了缺陷中看得见的那一半。`fetchCandidates` 读取同一个声明,候选请求仍会报告 `drilled: false`,`ui-reference` 也就仍会在指针进入的列表中逐行重复父目录。 + +**把 `execute` 推迟到 microtask,使重入的 track 必定落在 `settle` 之后。** 否决:该编辑携带 `hit.span` 用于版本 CAS,把它推迟到当前任务之外,会让插入其间的按键作废该 span,把一次本可成功的下钻变成静默失败。 + +**让 `applyEdit` 永不同步 flush。** 否决:`discrete` 正是让一次程序化编辑与由它算出的 detect 坐标留在同一个任务内的机制;为了修一个菜单标志而放宽它,会为所有调用方松开整个输入机的顺序保证。 + +## Consequences + +- Tab、行内 chevron 与 crumb 收敛到同一种行为,breadcrumb 不再取决于是哪种手势打开了列表。 +- 今后凡是 source 通过 `header` 或 `candidates` 读取的状态,都必须在 `execute` 之前发布,因为输入层可能在其内部重入 `track()`。该声明是控制器上的实例状态,顺序是唯一的约束手段。 +- 覆盖:一个 insert 监听器同步重新 track 的控制器用例——即指针顺序——断言两个读取方;`reference-composer.e2e.ts` 断言 chevron 下钻后的 breadcrumb 与精简后的行,并通过 crumb 点击走完两层路径的回退。键盘顺序保留原有用例,因此「修好一种手势却弄坏另一种」的回归会失败。 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 95e82385da..f8614e9ae7 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -130,6 +130,11 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n') await mkdir(join(scaffold.workspaceCwd, 'workspace', 'folderx'), { recursive: true }) await writeFile(join(scaffold.workspaceCwd, 'workspace', 'folderx', 'child.txt'), 'child fixture\n') + // Two levels down: the breadcrumb needs a step above the current one to + // return to, and a bare '@' lists only the top level, so the deeper tree + // stays out of the menu golden. + await mkdir(join(scaffold.workspaceCwd, 'workspace', 'folderx', 'nested'), { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, 'workspace', 'folderx', 'nested', 'leaf.txt'), 'leaf fixture\n') await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) @@ -280,13 +285,20 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => input.textContent()).toBe('@folderx/') await menu.getByRole('option', { name: /child\.txt/ }).waitFor() - // The row chevron drills the same way by pointer. + // The row chevron drills the same way by pointer, header included: a + // pointer descent reaches the same listing a Tab descent does. await writeComposerDraft(page, input, '@folderx') const row = menu.getByRole('option', { name: /^folderx\// }) await row.waitFor() await row.getByRole('button', { name: 'Browse folder' }).click() await expect.poll(() => input.textContent()).toBe('@folderx/') await menu.getByRole('option', { name: /child\.txt/ }).waitFor() + await expect.poll(() => page.getByRole('navigation', { name: 'Folder navigation' }) + .getByRole('button').allTextContents()).toEqual(['Workspace', 'folderx']) + // The listing knows it was drilled into, so its rows drop the location the + // header already carries. + await expect.poll(() => menu.getByRole('option', { name: /child\.txt/ }).textContent()) + .toBe('child.txt') await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) @@ -318,6 +330,20 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => menu.getByRole('option', { name: /child\.txt/ }).textContent()) .toBe('child.txt') + // A crumb above the current step re-lists that directory and keeps the + // header, which now names the step it returned to. + await writeComposerDraft(page, input, '@folderx/nested') + const nested = menu.getByRole('option', { name: /^nested\// }) + await nested.waitFor() + await nested.getByRole('button', { name: 'Browse folder' }).click() + await expect.poll(() => input.textContent()).toBe('@folderx/nested/') + await expect.poll(() => crumbs.getByRole('button').allTextContents()) + .toEqual(['Workspace', 'folderx', 'nested']) + await crumbs.getByRole('button', { name: 'folderx' }).click() + await expect.poll(() => input.textContent()).toBe('@folderx/') + await expect.poll(() => crumbs.getByRole('button').allTextContents()) + .toEqual(['Workspace', 'folderx']) + // Clicking the root crumb rewrites the token back to a bare trigger. await crumbs.getByRole('button', { name: 'Workspace' }).click() await expect.poll(() => input.textContent()).toBe('@') diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 883a795775..4afb3b5d04 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -490,13 +490,16 @@ export class InputTriggerController { }) this.stopFetch() this.reduce({ type: 'close' }) - const applied = this.execute(outcome, hit.span) - // Set after the close above, so the reducer's own teardown cannot clear - // it, and only when the descent text actually landed: a refused edit - // (stale draft revision, or no listener) leaves the draft where it was, - // and a header over that draft would name a directory nobody descended - // into while hiding the locations its rows still need. - this.drilled = action === 'drill' && applied + // Claimed before the edit, and after the close above so the reducer's own + // teardown cannot clear it: the input may apply the descent through a + // synchronous editor commit that re-enters track(), and the header and + // candidate requests raised there read this flag. A refused edit (stale + // draft revision, or an unmappable span) mutates nothing and so reaches + // no re-entry, which is why withdrawing the claim afterwards still keeps + // a header off a draft nobody descended into — one that would name a + // directory while hiding the locations its rows still need. + this.drilled = action === 'drill' + if (!this.execute(outcome, hit.span)) this.drilled = false } /** Re-poll every header-bearing source in the hit roster and publish their crumbs. */ 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 929d1e81e9..6872645dcf 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -595,11 +595,15 @@ describe('header / drilled descent', () => { /** A source that publishes one crumb per path segment of a drilled query. */ function crumbSource() { const requests: Array<{ query: string; quoted?: boolean; drilled: boolean }> = [] + const fetches: boolean[] = [] const picks: InputTriggerPick[] = [] const source: InputTriggerSource = { trigger: '@', name: 'reference', - candidates: () => Promise.resolve([{ name: 'src', drill: true, value: 'src' }]), + candidates: (_session, req) => { + fetches.push(req.drilled) + return Promise.resolve([{ name: 'src', drill: true, value: 'src' }]) + }, header: (_session, req) => { requests.push({ ...req }) if (!req.drilled || !req.query.includes('/')) return undefined @@ -610,7 +614,7 @@ describe('header / drilled descent', () => { return pick.action === 'drill' ? { text: `@${String(pick.candidate.value)}/`, continue: true } : undefined }, } - return { source, requests, picks } + return { source, requests, fetches, picks } } it('publishes no crumbs for a typed path and asks every source how the menu was reached', async () => { @@ -660,6 +664,26 @@ describe('header / drilled descent', () => { expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' }) }) + it('publishes crumbs when the input re-tracks inside the drill edit', async () => { + const { source, fetches } = crumbSource() + const { controller, actx } = controllerBench([source]) + // A pointer drill reaches the input outside any editor update, so the + // descent commits synchronously and re-tracks before the pick that asked + // for it has returned — the keyboard drill, dispatched inside an update, + // re-tracks only once that update commits. Both orders must reach the + // header and candidate requests as a drill. + actx.on('slash/input-insert-text', (req) => { + controller.track(req.text, req.text.length, { tier: 'plain' }, 2) + return true + }) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + await tick() + expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }]) + expect(fetches).toEqual([false, true]) + }) + it('publishes no crumbs when the input refused the drill edit', async () => { const { source } = crumbSource() const { controller } = controllerBench([source]) From 4d92a61e00db8932227276f0caea182bc18ad399 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:15:30 +0800 Subject: [PATCH 083/429] test(e2e): reserve pro for adapter coverage --- .../subagent-claude-code/tests/real-deepseek.e2e.ts | 11 ++++++----- snapshots/acp/escalation-approved/cordis.yml | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 9a8b3c2716..5611e4fce2 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -21,6 +21,7 @@ import * as claudeCode from '../src/index.ts' const execFileAsync = promisify(execFile) const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const DEEPSEEK_MODEL = 'deepseek-v4-flash' const sdkRoot = dirname(fileURLToPath( import.meta.resolve('@anthropic-ai/claude-agent-sdk'), )) @@ -90,11 +91,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( const env = { ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, - ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'deepseek-v4-pro[1m]', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v4-pro[1m]', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'deepseek-v4-flash', - CLAUDE_CODE_SUBAGENT_MODEL: 'deepseek-v4-flash', + ANTHROPIC_MODEL: DEEPSEEK_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL: DEEPSEEK_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL: DEEPSEEK_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL: DEEPSEEK_MODEL, + CLAUDE_CODE_SUBAGENT_MODEL: DEEPSEEK_MODEL, CLAUDE_CODE_EFFORT_LEVEL: 'max', CLAUDE_CONFIG_DIR: claudeConfig, HOME: root, diff --git a/snapshots/acp/escalation-approved/cordis.yml b/snapshots/acp/escalation-approved/cordis.yml index e08f3005c3..cc8f9609f9 100644 --- a/snapshots/acp/escalation-approved/cordis.yml +++ b/snapshots/acp/escalation-approved/cordis.yml @@ -34,7 +34,7 @@ name: '@deepseek-ai/dsh-acp' config: provider: deepseek-official - model: deepseek-v4-pro + model: deepseek-v4-flash - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' From 08bfeda7e8ec33cd25900d769f99b5e45b803c50 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:42:14 +0800 Subject: [PATCH 084/429] test(e2e): use Flash for live adapter coverage --- .../llm/llm-deepseek/tests/adapter.e2e.ts | 44 ++++++++----------- .../llm/llm-deepseek/tests/adapter.spec.ts | 4 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 38 +++++++++------- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- 4 files changed, 44 insertions(+), 46 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index cf3d511063..f39aebcc27 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -28,14 +28,13 @@ import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' /** - * Real-API e2e for the direct-fetch adapter: V4 Flash + V4 Pro across - * thinking modes and all official effort levels. The suite skips entirely - * without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally + * Real-API e2e for the direct-fetch adapter: V4 Flash across thinking modes + * and a max-effort tool round trip with reasoning passback. The suite skips + * entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally * requires $DEEPSEEK_VISION_E2E=1 (see vitest.e2e.config.ts). */ 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 TEST_PNG = Uint8Array.from(readFileSync( @@ -266,20 +265,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) }) - it.each(['high', 'max'] as const)( - 'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback', - async (effort) => { - const ctx = await harness(PRO, { thinking: 'enabled' }) + it( + 'flash + thinking enabled (effort max): tool-call round trip with reasoning passback', + async () => { + const ctx = await harness(FLASH, { thinking: 'enabled' }) // Turn 1: the model must call the tool (and think before it). const first = await assemble(ctx,{ - model: PRO, - reasoningEffort: ReasoningEffortId(effort), + model: FLASH, + reasoningEffort: ReasoningEffortId('max'), messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], maxTokens: 2000, }) - expect(first.finish.kind).toBe('tool-calls') + expect( + first.finish.kind, + `DeepSeek Flash tool-call turn finished as ${JSON.stringify(first.finish)}`, + ).toBe('tool-calls') const call = first.message.content.find(block => block.type === 'tool-call') expect(call).toBeDefined() expect(call!.name).toBe('get_weather') @@ -288,8 +290,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () // Turn 2: send the tool result back WITH the assistant's reasoning // block in history (the official thinking+tools passback rule). const second = await assemble(ctx,{ - model: PRO, - reasoningEffort: ReasoningEffortId(effort), + model: FLASH, + reasoningEffort: ReasoningEffortId('max'), messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), createMessage({ @@ -308,22 +310,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () tools: [weatherTool], maxTokens: 2000, }) - expect(second.finish.kind).toBe('stop') + expect( + second.finish.kind, + `DeepSeek Flash tool-result turn finished as ${JSON.stringify(second.finish)}`, + ).toBe('stop') expect(textOf(second).toLowerCase()).toMatch(/sunny|22/) }, ) - it('pro + thinking disabled: plain generation without reasoning blocks', async () => { - const ctx = await harness(PRO, { thinking: 'disabled' }) - const result = await assemble(ctx,{ - model: PRO, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - }) - expect(result.finish.kind).toBe('stop') - expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false) - }) - it('streams raw chunks in protocol order', async () => { const ctx = await harness(FLASH, { thinking: 'disabled' }) const kinds: string[] = [] diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 46d51efc43..39dd9e25b5 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -338,7 +338,7 @@ describe('DeepSeekAdapter against a mock server', () => { const ctx = await harness(server.url) const result = await assemble(ctx, { - model: 'deepseek-v4-flash', + model: 'deepseek-v4-pro', messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'plugin', plugin: 'test' }, @@ -350,7 +350,7 @@ describe('DeepSeekAdapter against a mock server', () => { // The wire request carried the auth header contents we configured. expect(server.requests[0]).toMatchObject({ - model: 'deepseek-v4-flash', + model: 'deepseek-v4-pro', max_tokens: 256_000, reasoning_effort: 'high', stream: true, diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 99b2be8721..93f684eef1 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -8,14 +8,12 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' /** - * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro with provider - * defaults and representative off/high/max reasoning. Mirrors the native - * adapter's StreamChunk contract and exercises a replayed tool follow-up. - * Key-gated. + * Real-API e2e for the pi-ai-backed adapter: V4 Flash defaults and + * off/high/max reasoning. Mirrors the native adapter's StreamChunk contract + * and exercises a replayed tool follow-up. Key-gated. */ const FLASH = 'deepseek-v4-flash' -const PRO = 'deepseek-v4-pro' const contexts: Context[] = [] async function harness(_model: string, config: Partial = {}) { @@ -67,10 +65,10 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => { - it.each([FLASH, PRO])('%s + provider-default reasoning: plain text generation', async (model) => { - const ctx = await harness(model) + it(`${FLASH} + provider-default reasoning: plain text generation`, async () => { + const ctx = await harness(FLASH) const result = await assemble(ctx,{ - model, + model: FLASH, messages: ask('Reply with exactly the word: pong'), maxTokens: 50, }) @@ -91,10 +89,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => expect(textOf(result).toLowerCase()).toContain('pong') }) - it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => { - const ctx = await harness(model) + it(`${FLASH} + reasoning high: reasoning blocks present`, async () => { + const ctx = await harness(FLASH) const result = await assemble(ctx,{ - model, + model: FLASH, reasoningEffort: ReasoningEffortId('high'), messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, @@ -104,24 +102,27 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => expect(textOf(result)).toContain('9.8') }) - it('pro + reasoning max: tool-call round trip', async () => { - const ctx = await harness(PRO) + it('flash + reasoning max: tool-call round trip', async () => { + const ctx = await harness(FLASH) const first = await assemble(ctx,{ - model: PRO, + model: FLASH, reasoningEffort: ReasoningEffortId('max'), messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], maxTokens: 2000, }) - expect(first.finish.kind).toBe('tool-calls') + expect( + first.finish.kind, + `pi-ai Flash tool-call turn finished as ${JSON.stringify(first.finish)}`, + ).toBe('tool-calls') const call = first.message.content.find(block => block.type === 'tool-call') expect(call).toBeDefined() expect(call!.name).toBe('get_weather') expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string }) const second = await assemble(ctx,{ - model: PRO, + model: FLASH, reasoningEffort: ReasoningEffortId('max'), messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), @@ -138,7 +139,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => tools: [weatherTool], maxTokens: 2000, }) - expect(second.finish.kind).toBe('stop') + expect( + second.finish.kind, + `pi-ai Flash tool-result turn finished as ${JSON.stringify(second.finish)}`, + ).toBe('stop') expect(textOf(second).toLowerCase()).toMatch(/sunny|22/) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 9b35c6f285..cb2e71fad1 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -135,14 +135,14 @@ describe('PiAiAdapter provider routing', () => { thinkingBudgets: { high: 2048 }, }) await assemble(ctx, { - model: 'deepseek-v4-flash', + model: 'deepseek-v4-pro', messages: [], temperature: 0.2, maxTokens: 77, sessionId: 'session-for-pi' as never, }) expect(server.requests[0]).toMatchObject({ - model: 'deepseek-v4-flash', + model: 'deepseek-v4-pro', temperature: 0.2, max_tokens: 77, thinking: { type: 'enabled' }, From e5f36cc70fd63fe4e8ca12ef8d4723eee861e8f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 17:13:45 +0800 Subject: [PATCH 085/429] feat(plugin-inventory): carry every agent preset's composition and group the settings plugin list by scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings plugin list projected ctx.loader.entries() alone, hiding the plugins sessions actually run and rendering the web overlay's deliberate disabled tombstones (tool-bash, tool-fs, ...) as two dozen plainly disabled rows while the same modules ran in every standard-preset session. - dsh-agent-presets: compositionInventory() answers flattened rows per preset — newest live standing generation when mounted, composition file otherwise with !!js disabled gates evaluated against the Loader context; reading never mounts (regression-tested), refusal stays 'conditional', raced files report broken with the reason. - dsh-host-plugin-inventory: list() gains an optional agentPresets block, resolving the roster as an optional peer and mapping fiber states to the public phase vocabulary. - ui-settings-plugin-inventory: preset group first behind a display-only switcher opening on the default preset; global group collapsed with failures floated; host-disabled modules enabled by >=1 preset fold into a session-plugins drawer naming providers; search spans scopes and points at matches in unselected presets. - ui-agent-preset: the General-settings default-preset row is deleted — the roster section's make-default and the new-session chip keep the field — and the settings store slims to the display roster the header label reads. Docs, catalogs, module graph, settings-chrome goldens, and the bilingual Agent Note ride along. --- ...in-inventory-agent-preset-scopes.i18n.yaml | 6 + ...29-plugin-inventory-agent-preset-scopes.md | 33 ++ ...plugin-inventory-agent-preset-scopes.zh.md | 33 ++ .../settings-chrome/dialog-en.expected.md | 4 - .../settings-chrome/dialog.expected.md | 4 - .../settings-chrome/plugins.expected.md | 4 +- apps/web/tests/settings-chrome.e2e.ts | 15 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 16 + docs/subsystems/core.zh.md | 16 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 8 +- packages/client/ui-agent-preset/README.zh.md | 8 +- .../src/client/AgentPresetRow.module.css | 60 --- .../src/client/AgentPresetRow.tsx | 89 ---- .../ui-agent-preset/src/client/PresetMenu.tsx | 84 ---- .../ui-agent-preset/src/client/index.ts | 47 +- .../ui-agent-preset/src/client/locales.ts | 10 +- .../src/client/settings-store.ts | 93 +--- .../tests/apply.client.spec.ts | 32 +- .../tests/components.client.spec.tsx | 140 +----- .../tests/settings-store.client.spec.ts | 129 ++--- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 14 +- .../ui-settings-plugin-inventory/README.zh.md | 14 +- .../PluginInventorySettingsTab.module.css | 172 ++++++- .../src/client/PluginInventorySettingsTab.tsx | 441 ++++++++++++++---- .../src/client/locales.ts | 56 ++- .../tests/components.client.spec.tsx | 270 ++++++++--- .../src/client/slot-catalog.ts | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 22 + .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 15 +- packages/host/plugin-inventory/README.zh.md | 15 +- packages/host/plugin-inventory/package.json | 7 + packages/host/plugin-inventory/src/index.ts | 26 +- packages/host/plugin-inventory/src/types.ts | 40 ++ .../plugin-inventory/tests/inventory.spec.ts | 43 +- packages/host/plugin-inventory/tsconfig.json | 3 + .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 5 + packages/preset/agent-presets/README.zh.md | 5 + .../src/composition-inventory.ts | 195 ++++++++ .../preset/agent-presets/src/discovery.ts | 5 +- packages/preset/agent-presets/src/index.ts | 55 ++- .../tests/composition-inventory.spec.ts | 309 ++++++++++++ packages/preset/agent-presets/tsconfig.json | 3 + pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 1 + 52 files changed, 1769 insertions(+), 824 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md create mode 100644 .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md delete mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css delete mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx delete mode 100644 packages/client/ui-agent-preset/src/client/PresetMenu.tsx create mode 100644 packages/preset/agent-presets/src/composition-inventory.ts create mode 100644 packages/preset/agent-presets/tests/composition-inventory.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml new file mode 100644 index 0000000000..bb1705fca7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.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-29-plugin-inventory-agent-preset-scopes.md +2026-08-29-plugin-inventory-agent-preset-scopes.md: ec738ee0ff325e14677407da0ce4af8ffe3fe718 +2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: 7804203350b4173a6dfd058447aee95e918cda74 diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md new file mode 100644 index 0000000000..ec738ee0ff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md @@ -0,0 +1,33 @@ +# Agent Note: The plugin inventory carries every agent preset's composition + +Status: implemented + +English | [中文](2026-08-29-plugin-inventory-agent-preset-scopes.zh.md) + +## Problem + +[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and the settings plugin list kept projecting `ctx.loader.entries()` alone. The surface therefore hid the plugins sessions actually run — a directly-plugged preset subtree never appears in the Loader's entries — and actively misled about the rest: the web overlay's deliberate `disabled: true` tombstones (`tool-bash`, `tool-fs`, `plan-mode`, …) rendered as two dozen plainly "disabled" rows while the same modules ran in every standard-preset session. Beside it, General settings carried a default-preset dropdown that wrote the same `agent-presets.default` field as the roster section's own make-default action — two editors for one fact, one of them blind to the roster it was choosing from. + +## Decision + +**The inventory speaks for both planes.** `pluginInventory/list` gains an optional `agentPresets` block — one group per roster preset with id, display name, default marking, health, and flattened composition rows — supplied by the new `AgentPresets.compositionInventory()`: a preset with a live standing mount answers from its newest generation's Loader entries, and one never composed since boot answers from its composition file. `dsh-host-plugin-inventory` resolves the roster as an optional peer through `ctx.get('agentPresets')` (the `plugin-package-inventory-deepseek` pattern) and only maps root-fiber states onto its public phase vocabulary, so deployments without a roster keep serving Loader entries alone with the field absent. + +**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read. + +**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first behind a display-only switcher that opens on the default preset and writes no settings — inspecting `minimal` must not change what new sessions run. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled folds into a "session plugins" drawer that names its providers — a third state instead of the generic "disabled" that started this. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces the disclosures open, and points at matches sitting in unselected presets. + +**The General row is deleted, not relocated.** The default keeps two surfaces that can still act on it — the roster section's make-default beside the visible roster, and the new-session chip for the session about to start — so `ui-agent-preset` drops the row, its menu, and the write/writability half of its settings store, which slims to the display roster the header label reads. + +## Alternatives considered + +**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the drawer's provider list and the search pointers preserve the cross-scope answer the all-at-once layout was buying. + +**Keep file-state gates unevaluated (`conditional` until first mount).** Honest but it re-created the misleading reading this change removes: on a cold host the default preset's `tool-bash` read as "conditional" and its host row fell back to plain "disabled" until the first session mounted the preset. + +**A structured composition viewer in the Agent presets section.** A second home for the same rows; the section keeps its raw-YAML viewer for authors and the plugin list owns the structured view. + +**Enable/disable toggles in the same change.** Writing a row's `disabled` back into a custom preset's `agent.cordis.yml` needs comment-preserving partial YAML edits, applies-to-new-sessions messaging, and a copy-then-edit path for shipped presets — deliberately its own change; this one is read-side truth. + +## Consequences + +Searching "bash" now answers the question that motivated the change in one screen: enabled in the standard preset, provided per session where the global plane disabled it, plainly disabled only where nothing enables it. The wire snapshot's row enablement is the union `boolean | 'conditional'` with the gate expression beside it, and the settings-chrome goldens pin the grouped layout. `ui-agent-preset` loses `AgentPresetRow` and `PresetMenu`; the `settings.agentPreset` locale namespace declaration moved to the plugin entry, and the `settings-chrome` English scenario probes locale resolution through the nav label instead of the deleted row. diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md new file mode 100644 index 0000000000..7804203350 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md @@ -0,0 +1,33 @@ +# Agent Note:插件清单携带每个 Agent 预设的组合 + +状态:已实现 + +[English](2026-08-29-plugin-inventory-agent-preset-scopes.md) | 中文 + +## 问题 + +[按会话的 agent preset](2026-08-03-per-session-agent-presets.zh.md) 把所有模型侧行移到了 agent 平面,而设置页的插件列表仍只投影 `ctx.loader.entries()`。这个表面因此看不见会话实际运行的插件——直接 plug 的预设子树从不出现在 Loader 条目里——还对其余部分构成误导:web overlay 刻意的 `disabled: true` 墓碑(`tool-bash`、`tool-fs`、`plan-mode`……)渲染成二十多行看似单纯"已停用"的条目,而同名模块在每个标准模式会话里运行。旁边,通用设置还有一个默认预设下拉,与名单分区自己的设为默认动作写同一个 `agent-presets.default` 字段——同一事实两个编辑器,其中一个还看不见它在选择的名单。 + +## 决定 + +**清单同时陈述两个平面。**`pluginInventory/list` 增加可选的 `agentPresets` 块——每个名单预设一组,含 id、显示名、默认标记、健康状态与压平的组合行——由新增的 `AgentPresets.compositionInventory()` 提供:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答,开机以来从未被组合的预设由其组合文件作答。`dsh-host-plugin-inventory` 经 `ctx.get('agentPresets')` 把名单当作可选伙伴解析(即 `plugin-package-inventory-deepseek` 的模式),自己只把根 Fiber 状态映射到公共阶段词汇,因此没有名单的部署继续只提供 Loader 条目、字段缺席。 + +**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。 + +**列表按作用域分组,误导行获得自己的状态。**预设组在前,其切换器只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,收进"会话插件"抽屉并列出提供它的预设——用第三种状态取代引发这一切的笼统"已停用"。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开折叠,并指出未选中预设里的匹配。 + +**通用设置行是删除,不是搬家。**默认值保留两个仍能作用于它的表面——名单分区的设为默认(名单可见)与新会话 chip(针对即将开始的会话)——因此 `ui-agent-preset` 删掉该行、它的菜单以及 settings store 的写入/可写性半边,后者收敛为标题标签读取的展示名单 store。 + +## 考虑过的替代方案 + +**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,抽屉的提供者列表与搜索指引保留了全展开布局想买到的跨作用域答案。 + +**文件态门保持不求值(首次挂载前一律 `conditional`)。**诚实,但重演了本次要消除的误导:冷启动的宿主上,默认预设的 `tool-bash` 读作"条件启用",其全局行在第一个会话挂载预设之前退回单纯的"已停用"。 + +**在 Agent 预设分区做结构化组合查看器。**同一批行的第二个家;分区保留面向作者的原始 YAML 查看器,插件列表拥有结构化视图。 + +**启停开关随本次一起做。**把行的 `disabled` 写回自定义预设的 `agent.cordis.yml` 需要保注释的局部 YAML 编辑、"对新会话生效"的提示,以及内置预设的复制后编辑路径——刻意留作独立改动;本次只做读侧真相。 + +## 后果 + +搜索 "bash" 现在一屏回答引发本次改动的问题:在标准模式里启用、在全局平面被停用处按会话提供、只有真的无人启用之处才是单纯的已停用。线上快照的行启停是联合类型 `boolean | 'conditional'` 并携带门表达式,settings-chrome 的 golden 钉住分组布局。`ui-agent-preset` 失去 `AgentPresetRow` 与 `PresetMenu`;`settings.agentPreset` 文案命名空间声明移到插件入口,`settings-chrome` 的英文场景改用导航标签而非已删除的行来探测 locale 解析。 diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 10f4e568fe..c47e53bf6c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -17,10 +17,6 @@ - button "Close": - img - text: Close - - text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with. - - button "Standard mode": - - text: Standard mode - - img - text: Permission Choose the default permission mode for new sessions - button "Workspace Write": - text: Workspace Write diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 1aa949a86d..369dc19e71 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -17,10 +17,6 @@ - button "关闭": - img - text: 关闭 - - text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的预设。 - - button "标准模式": - - text: 标准模式 - - img - text: 权限 选择新会话的默认权限模式 - button "Workspace Write": - text: Workspace Write diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index 9e8362a942..c4e0cbf9f0 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -1,6 +1,6 @@ - listitem: - - button "ui-settings, 已挂载, 已启用": + - button "ui-settings, 已启用": - strong: ui-settings - - img "已挂载" + - img "运行中" - text: 已启用 - img diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 96673df5f8..4a8fe18989 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -102,13 +102,22 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '插件', exact: true }).click() await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() + // The preset group opens first with its display-only switcher; the global + // plane starts collapsed and expands on demand, session plugins deeper still. + const presetSwitcher = dialog.getByRole('combobox', { name: '选择要查看的 Agent 预设' }) + await presetSwitcher.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: /^全局/ }).click() const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) await pluginRow.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: /^会话插件/ }).click() const expectedPluginCount = [...scaffold.ctx.loader.entries()] .filter(entry => !entry.options.group) .length expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) - expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount) + // Every Loader entry appears exactly once in the global group — the + // session-plugin drawer included, preset compositions excluded. + expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count()) + .toBe(expectedPluginCount) expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) .toBe(String(expectedPluginCount)) expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') @@ -597,8 +606,8 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) - const preset = dialog.getByRole('button', { name: 'Standard mode' }) - await expect.poll(() => preset.isEnabled(), { timeout: 10_000 }).toBe(true) + // A locale-owned nav label proves the dictionaries resolved to en. + await dialog.getByRole('button', { name: 'Agent presets' }).waitFor({ timeout: 10_000 }) // The markup already ships `en`, so this alone cannot prove the sync ran // — the zh scenario above is the discriminating half. Asserted here too // so a future change that resolves en but writes the wrong tag is caught. diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 445c9ac17a..dee3e77226 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: b48c197299f1afbf53bf60962f544148f7f6defc -module-graph.zh.md: 1f85ab06c3ca98d6060dab400d2bf8dbfbda8cc5 +module-graph.md: 118aab0cf70b0c7dc9f1279941f9160e158db73d +module-graph.zh.md: d22990142c454d8ec7649b52dd27557c6b71350e diff --git a/docs/module-graph.md b/docs/module-graph.md index b48c197299..118aab0cf7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -405,9 +405,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout - pkg_host_plugin_inventory --> pkg_brand - pkg_host_plugin_inventory --> pkg_invariants - pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants @@ -1040,6 +1037,10 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_typert_protocol pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1753,7 +1754,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1881,6 +1881,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1f85ab06c3..d22990142c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -407,9 +407,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout - pkg_host_plugin_inventory --> pkg_brand - pkg_host_plugin_inventory --> pkg_invariants - pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants @@ -1042,6 +1039,10 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_typert_protocol pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1755,7 +1756,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1883,6 +1883,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 06bfe0a937..2ce0d29de1 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: dbe12dfc9bfdafe0f59d7e52eefc5695b1c8d063 -core.zh.md: 50fd2e4e76ee72653c02c2ffeca06042191aa9c4 +core.md: 8af708c4e12677e376bfc2adf2ff52490f5ec1a5 +core.zh.md: 1680e51a9f3a0363c28b3582d8a3e5399d5b2530 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index dbe12dfc9b..8af708c4e1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -410,6 +410,22 @@ async list(): Promise */ @Remote('list') async remoteExportList(): Promise +/** + * Every preset's composition as flattened plugin rows, for plugin-listing + * surfaces beside the roster's own picker. + * + * A preset with a live standing mount answers from its newest generation's + * Loader entries — the composition new sessions join — and one never + * composed since boot answers from its file, with `!!js` disabled gates + * evaluated against the Loader context so both answers reflect the same + * host. Reading never mounts: an unmounted preset is parsed, not composed, + * so listing a preset's plugins cannot activate them early. A composition + * that stopped reading between discovery's health verdict and this read is + * reported broken with the raced reason rather than dropped. + * @returns one composition per roster preset, in roster order. + */ +async compositionInventory(): Promise + /** * Resolve one preset by id. * diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 50fd2e4e76..1680e51a9f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -420,6 +420,22 @@ async list(): Promise */ @Remote('list') async remoteExportList(): Promise +/** + * Every preset's composition as flattened plugin rows, for plugin-listing + * surfaces beside the roster's own picker. + * + * A preset with a live standing mount answers from its newest generation's + * Loader entries — the composition new sessions join — and one never + * composed since boot answers from its file, with `!!js` disabled gates + * evaluated against the Loader context so both answers reflect the same + * host. Reading never mounts: an unmounted preset is parsed, not composed, + * so listing a preset's plugins cannot activate them early. A composition + * that stopped reading between discovery's health verdict and this read is + * reported broken with the raced reason rather than dropped. + * @returns one composition per roster preset, in roster order. + */ +async compositionInventory(): Promise + /** * Resolve one preset by id. * diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 042caf6f1b..3e1e1a14de 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 60a7f0ec9356c6107a32bb7c828746d54511d0f6 -README.zh.md: 64663fd76e0a8e3fd4cb799b05e81db7b62c0bde +README.md: e6bf3d1633e3d6bd6988692241803fa9b59898ac +README.zh.md: 5f202f53a20ac8a35574ddf715d27d86926a45d1 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 60a7f0ec93..e6bf3d1633 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the agent-preset surfaces of the Web GUI: a General-settings row choosing which preset new sessions are composed from, a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with. When a deployment composes no presets, all four surfaces render nothing and every session shares the host composition. +This package provides the agent-preset surfaces of the Web GUI: a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with; the default preset is edited in the settings section, where the roster is visible, so General settings carries no duplicate control for the same field. When a deployment composes no presets, all three surfaces render nothing and every session shares the host composition. ## Table of Contents @@ -25,7 +25,7 @@ This package provides the agent-preset surfaces of the Web GUI: a General-settin ## Use this package -Mount this plugin alongside the settings and conversation packages; the preset surfaces then appear where their slots render. The General-settings row opens on the deployment default and applies to sessions started afterwards; the new-session chip stages a pick that lands on the next blank session and is spent on first use, so the following new session opens on the default again. +Mount this plugin alongside the settings and conversation packages; the preset surfaces then appear where their slots render. The new-session chip opens on the deployment default and stages a pick that lands on the next blank session; the stage is spent on first use, so the following new session opens on the default again. ### Managing the roster @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card
Implementation internals — click to expand -Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +The display options come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so no surface introspects the settings schema — and the default write, the settings section's make-default action, targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
@@ -56,7 +56,7 @@ Read these pages when the preset surface is not enough. They move from the brows - [dsh-agent-presets](../../preset/agent-presets/README.md) — the host roster and composition the surfaces read and manage. - [ui-conversation](../ui-conversation/README.md) — declares the hero and session-header slots the chip and label fill. -- [ui-settings](../ui-settings/README.md) — the settings shell that hosts the General row and the roster section. +- [ui-settings](../ui-settings/README.md) — the settings shell that hosts the roster section. - [Client package map](../README.md) — adjacent browser UI packages. ----- diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 64663fd76e..5f202f53a2 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的 agent preset 表面:通用设置中的一行,选择新建会话据以组装的 preset;新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装。当部署未组装任何 preset 时,四个表面都不渲染任何内容,每个会话共用宿主组装。 +本包提供 Web GUI 的 agent preset 表面:新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装;默认 preset 在能看到名单的设置分区里编辑,通用设置不再为同一字段保留重复控件。当部署未组装任何 preset 时,三个表面都不渲染任何内容,每个会话共用宿主组装。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与设置与对话包一起挂载本插件;preset 表面随即出现在各自槽位渲染之处。通用设置行以部署默认值打开,作用于此后开启的会话;新建会话 chip 暂存一个选择,落到下一个空白会话上,一经使用即被清空,因此再下一个新会话重新以默认值打开。 +与设置与对话包一起挂载本插件;preset 表面随即出现在各自槽位渲染之处。新建会话 chip 以部署默认值打开并暂存一个选择,落到下一个空白会话上;暂存一经使用即被清空,因此再下一个新会话重新以默认值打开。 ### 管理名单 @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。 +展示选项来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此任何表面都无需对 settings schema 做内省——默认值的写入即设置分区的设为默认动作,目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
@@ -56,7 +56,7 @@ kind: "package-reference" - [dsh-agent-presets](../../preset/agent-presets/README.zh.md)——这些表面读取并管理的宿主名单与组装。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 chip 与标签填充的首屏与会话头部槽位。 -- [ui-settings](../ui-settings/README.zh.md)——承载通用行与名单分区的设置外壳。 +- [ui-settings](../ui-settings/README.zh.md)——承载名单分区的设置外壳。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 ----- diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css deleted file mode 100644 index d0f7134329..0000000000 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css +++ /dev/null @@ -1,60 +0,0 @@ -/* Agent-preset row: title/description plus the preset selector pill. */ - -.row { - display: flex; - align-items: center; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -.rowText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding-right: 48px; -} - -.title { - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: var(--dsw-alias-label-primary); -} - -.desc { - font-size: 12px; - font-weight: 400; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -.selector { - display: inline-flex; - align-items: center; - gap: 12px; - height: 36px; - padding: 0 14px; - border: none; - border-radius: 18px; - background: var(--dsw-alias-bg-module-platform); - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - -.selector:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.selector:disabled { - cursor: default; -} - -.chevron { - flex: none; -} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx deleted file mode 100644 index d2338596ba..0000000000 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Agent-preset preference row: the preset new sessions are composed from. - * A running session keeps the composition it began with, so this row never - * disturbs work in progress. - */ - -import { useEffect, useState } from 'react' -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' -import { PresetMenu } from './PresetMenu.tsx' -import css from './AgentPresetRow.module.css' - -/** Registration-side business face for the host-backed preference. */ -export interface AgentPresetRowInjected { - hooks: { - /** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */ - agentPreset: SnapshotStore - } - /** Load the roster when the row first renders. */ - load: () => Promise - /** Persist one preset as the default for later sessions. */ - select: (id: string) => Promise -} - -/** Full component props. */ -export type AgentPresetRowProps = - PropsRuntime<'settings.general.item'> - & PropsLocale<'settings.agentPreset'> - & InjectFace - -/** - * Render the new-session agent-preset selector. - * @param props - composed slot props. - * @returns the row, or null when the deployment composes no presets. - */ -export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) { - const state = useAgentPreset(snapshot => snapshot) - const [open, setOpen] = useState(false) - - useEffect(() => { - void load() - }, [load]) - - useEffect(() => { - if (state.writable && state.status !== 'unavailable') return - setOpen(false) - }, [state.status, state.writable]) - - // A deployment that composes no presets has nothing to choose between, and - // every session shares the host composition — the row simply does not exist. - if (state.status === 'unavailable') return null - const busy = state.status === 'loading' || state.status === 'saving' - // Every preset surface applies the same display-copy rule. The id remains - // addressing rather than a label, except where no display name exists. - const chosen = state.options.find(option => option.id === state.currentValue) - const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) - const label = state.currentValue === '' ? t('loading') : (chosenText?.name ?? state.currentValue) - const description: string = state.error ?? t('description') - - return ( -
-
-
{t('title')}
-
{description}
-
- { void select(id) }} - /> -
- ) -} - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Agent-preset row copy. */ - 'settings.agentPreset': AgentPresetSettingsKey - } -} diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx deleted file mode 100644 index 4b78d8ce6e..0000000000 --- a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * The preset picker both surfaces render: a menu of presets over a button - * naming the current one. - * - * The settings row and the composer seat differ in where they sit, what they - * call the current value, and when they refuse a pick — not in how the picker - * itself behaves. Trust is the one thing the list always says: a locally - * authored preset is exactly as privileged as the plugins it names, so the - * label marks it rather than presenting every preset as shipped and vetted. - */ - -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' -import type { AgentPresetOption } from './settings-store.ts' -import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' - -/** What one surface passes to the shared picker. */ -export interface PresetMenuProps { - /** Presets to offer, in roster order. */ - options: readonly AgentPresetOption[] - /** The preset the button names and the menu marks selected. */ - selectedId: string - /** Text on the button; the surfaces word a pending roster differently. */ - label: string - /** Active Web locale lookup. */ - t: (key: AgentPresetSettingsKey) => string - /** Class for the trigger button, owned by the calling surface. */ - buttonClassName: string | undefined - /** Class for the chevron, owned by the calling surface. */ - chevronClassName: string | undefined - /** Whether the trigger refuses interaction. */ - disabled: boolean - /** Whether the menu is open — the surface owns this so it can force it shut. */ - open: boolean - /** Report the menu's next open state. */ - onOpenChange: (open: boolean) => void - /** Called with the picked preset once the menu has closed. */ - onSelect: (id: string) => void -} - -/** - * Render the preset picker. - * @param props - the calling surface's copy, styling, and handlers. - * @returns the menu and its trigger. - */ -export function PresetMenu({ - options, selectedId, label, t, buttonClassName, chevronClassName, - disabled, open, onOpenChange, onSelect, -}: PresetMenuProps) { - return ( -
{ onOpenChange(false) }} - items={options.map((option) => { - const name = presetDisplayText(option, t).name - return { - id: option.id, - // All preset surfaces resolve copy the same way; the id is addressing, - // not a label, except where no display name exists. - label: option.trust === 'user' ? `${name} · ${t('userTrust')}` : name, - } - })} - selectedId={selectedId} - onSelect={(id) => { - onOpenChange(false) - onSelect(id) - }} - align="end" - portal - anchor={( - - )} - /> - ) -} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4926d7c001..3b1e608de4 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -1,14 +1,15 @@ /** - * Agent-preset surface plugin, browser half — four surfaces over one roster: - * a General-settings row for the default preset, a chip on the new-session - * screen for the session about to start, a read-only label in the session - * header, and a settings section that manages the roster (copy, delete, - * default, and the way into a preset's own files). + * Agent-preset surface plugin, browser half — three surfaces over one roster: + * a chip on the new-session screen for the session about to start, a + * read-only label in the session header, and a settings section that manages + * the roster (copy, delete, default, and the way into a preset's own files). * * A running session keeps the composition it began with (the host refuses to * adopt an existing session under a different preset). That is what splits - * the choice from the display: the General row and the hero chip are both - * before-the-fact, while the header only reports what a session already runs. + * the choice from the display: the hero chip is before-the-fact, while the + * header only reports what a session already runs. The default preset is + * edited where the roster is visible — the settings section's "make default" + * — so General settings carries no duplicate control for the same field. */ // Type-only: pulls the Session Controller service merge (ctx.sessions). @@ -26,19 +27,23 @@ 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' -import { AgentPresetRow } from './AgentPresetRow.tsx' -import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' import { AgentPresetSeat } from './AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' import { AgentPresetSection } from './AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' import { AgentPresetSeatController } from './seat-store.ts' import { AgentPresetSectionController } from './section-store.ts' -import { en, zh } from './locales.ts' +import { en, zh, type AgentPresetSettingsKey } from './locales.ts' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent-preset surface copy. */ + 'settings.agentPreset': AgentPresetSettingsKey + } +} + export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx' -export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' export type { AgentPresetSeatState } from './seat-store.ts' @@ -50,16 +55,15 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', ] /** - * Mount the General-settings row. + * Mount the roster surfaces: hero chip, session-header label, settings section. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const settingsWire = { settings: ctx.remote.settings } - const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) + const controller = new AgentPresetSettingsController(ctx.remote) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() @@ -70,12 +74,6 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') - const injected = (): AgentPresetRowInjected => ({ - hooks: { agentPreset: controller.store }, - load: () => controller.load(), - select: (id: string) => controller.select(id), - }) - ctx.effect(() => { // The roster is a live directory and the default is a settings field, so // both an external settings edit and a reconnect can move this row. @@ -193,13 +191,6 @@ export function apply(ctx: ClientContext): void { makeDefault: (id: string) => section.makeDefault(id), }) - ctx.slots.inject('settings.general.item', () => ctx.slots.register({ - name: 'settings.general.item', - id: 'agent-preset', - order: -25, - locale: 'settings.agentPreset', - inject: injected, - }, AgentPresetRow)) // Ordered after Models: choosing a model is routine, and composing an // agent is the deployment-shaping act behind it. ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index b8e9f8b6e2..64b9d4b0ad 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -1,8 +1,8 @@ -/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */ +/** Locale bundles for the agent-preset hero chip, header label, and management section. */ /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = - | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' + | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' | 'presetStandardName' | 'presetStandardDescription' | 'presetPtcName' | 'presetPtcDescription' @@ -20,9 +20,6 @@ export type AgentPresetSettingsKey = /** English copy. */ export const en: Record = { - title: 'Agent preset', - description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.', - loading: 'Loading presets…', error: 'Could not load agent presets.', userTrust: 'Custom', seatHint: 'Agent preset for the session you are about to start', @@ -87,9 +84,6 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - title: 'Agent 预设', - description: '对此后新建的会话生效。运行中的会话保持它开始时的预设。', - loading: '正在加载预设…', error: '无法加载 Agent 预设。', userTrust: '自定义', seatHint: '即将开始的这个会话所用的 Agent 预设', 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 398b781e67..df8bed68d4 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -1,16 +1,15 @@ /** - * Agent-preset default-settings controller. + * Agent-preset roster store shared by the display surfaces. * - * Options and the current default both come from one `agentPresets.list` call: - * the roster already reports which id a session with no explicit choice gets, - * so the row needs no schema introspection. Writes target the settings - * namespace's `default` field, which is what the host resolves at creation. + * Options come from one `agentPresets.list` call. Writes target the settings + * namespace's `default` field, which is what the host resolves at creation; + * the management section is the surface that writes it. */ import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' -import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' @@ -29,9 +28,9 @@ export function messageOf(error: unknown): string { /** * Persist one preset as the default for sessions created later. * - * The default is a settings field rather than a preset property, so both the - * General row and the management section write it here — one home for which - * namespace and field the host resolves at session creation. + * The default is a settings field rather than a preset property; the + * management section writes it here — one home for which namespace and field + * the host resolves at session creation. * @param api - the settings wire face. * @param id - the preset to make default. * @returns the failure message, or undefined once the write landed. @@ -126,14 +125,14 @@ export async function beginRosterRead = createSnapshotStore(INITIAL) /** - * @param api - the settings wire face (the default write). * @param remote - the agent-preset Remote namespace (the roster read). - * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: SettingsWireFace, private readonly remote: Pick, - private readonly describeFace: SettingsDescribeFace, ) {} private set(patch: Partial): void { @@ -196,53 +179,23 @@ export class AgentPresetSettingsController { /** * Load the roster. An empty roster means the deployment composes no - * presets, which is a valid deployment rather than a failure — the row - * reports `unavailable` and renders nothing. + * presets, which is a valid deployment rather than a failure — the + * surfaces report `unavailable` and render nothing. * @returns once the snapshot reflects the host. */ async load(): Promise { const roster = await beginRosterRead(this.remote, this.store) if (roster === undefined) return const { presets } = roster - const [first] = presets - if (first === undefined) { - this.set({ status: 'unavailable', options: [], currentValue: '' }) + if (presets.length === 0) { + this.set({ status: 'unavailable', options: [] }) return } - // The roster says what may be chosen; the shared mirror says whether this - // browser may write the choice down. A non-loopback browser's mirror never - // answers, so the row stays read-only rather than offering a control - // whose write the Host would refuse. - await this.describeFace.ensure() this.set({ status: 'ready', error: null, - writable: this.describeFace.getSnapshot().view?.writable ?? false, options: presetOptions(presets), - // A roster can mark nothing default: settings can name a preset that - // was since deleted, and the picker still has to show something. - currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, }) } - /** - * Persist one preset as the default for sessions created later. Running - * sessions keep the composition they were created with, so this never - * disturbs work in progress. - * @param id - the preset to make default. - * @returns once the write settled and the roster was re-read. - */ - async select(id: string): Promise { - const before = this.store.getSnapshot() - if (before.status === 'saving' || id === before.currentValue) return - this.set({ status: 'saving', error: null, currentValue: id }) - const failure = await writeDefaultPreset(this.api, id) - if (failure !== undefined) { - this.set({ status: 'ready', currentValue: before.currentValue, error: failure }) - return - } - // Re-read rather than trust the patch: the host resolves the default - // through the same roster the row displays. - await this.load() - } } 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 c2488de4af..a4fc61c717 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -16,8 +16,6 @@ import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/d import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' -import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' -import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx' import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx' import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' @@ -177,19 +175,19 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', ]) }) - it('registers the General row and the settings section', async () => { + it('registers the settings section and no General row', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - const row = slots.entries('settings.general.item')[0]! - expect(row.component).toBe(AgentPresetRow) - expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 }) + // The default preset is edited in the section, where the roster is + // visible; a General row would duplicate the same settings field. + expect(slots.entries('settings.general.item')).toHaveLength(0) const section = slots.entries('settings.section')[0]! expect(section.component).toBe(AgentPresetSection) expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 }) @@ -206,21 +204,14 @@ describe('ui-agent-preset apply', () => { await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) }) - it('hands each surface its own store and actions', async () => { + it('hands the section its own store and default write', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() - expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection) - // Each thunk reaches its own controller: the row's load fills the row's - // store, and the section's default write does not go through the row. - await row.load() - await row.select('standard') await section.makeDefault('standard') - expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) expect(section.hooks.agentPresetSection.getSnapshot().rows) .toEqual([{ id: 'standard', trust: 'system', isDefault: true }]) }) @@ -294,8 +285,8 @@ describe('ui-agent-preset apply', () => { remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) - // Only the General row reloads: a section nobody opened has nothing to - // converge, and reading the roster for it would be a wasted round trip. + // Only the header label's roster reloads: a section nobody opened has + // nothing to converge, and reading the roster for it would be wasted. expect(calls.length - before).toBe(1) }) @@ -471,7 +462,7 @@ describe('ui-agent-preset apply', () => { expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent) }) - it('gives the header label the same roster the General row reads', async () => { + it('loads the header label from the shared roster store', async () => { const { ctx, slots } = await bench() declareRoot(slots) declareConversation(slots) @@ -481,14 +472,9 @@ describe('ui-agent-preset apply', () => { 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]! - .inject as unknown as () => AgentPresetRowInjected)() await label.load() - // One roster behind both: the label resolves a name the settings row's own - // load already fetched, rather than issuing a second read per session. - expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset) expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) }) 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 b2c9bcfac6..5a3904efa3 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom /** - * The three conversation-adjacent surfaces: the General-settings row naming the - * default for later sessions, the new-session chip naming the next one's, and - * the session header's read-only label. The split is the host's rule — a - * session's history is produced under its preset's tools, so the choice is - * only ever offered before one starts. + * The two conversation-adjacent surfaces: the new-session chip naming the + * next session's preset, and the session header's read-only label. The split + * is the host's rule — a session's history is produced under its preset's + * tools, so the choice is only ever offered before one starts. */ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -13,8 +12,6 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' 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' -import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx' import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSettingsState } from '../src/client/settings-store.ts' @@ -23,13 +20,9 @@ import { en } from '../src/client/locales.ts' afterEach(cleanup) -const ROW_READY: AgentPresetSettingsState = { +const ROSTER_READY: AgentPresetSettingsState = { status: 'ready', error: null, - writable: true, - currentValue: 'standard', - // `mine` deliberately names itself nothing: the row must fall back to the - // id for a preset whose author wrote no metadata. options: [{ id: 'standard', trust: 'system', name: '标准模式' }, { id: 'mine', trust: 'user' }], } @@ -44,17 +37,6 @@ const SEAT_READY: AgentPresetSeatState = { introduce: false, } -function renderRow(state: Partial = {}) { - const store = createSnapshotStore({ ...ROW_READY, ...state }) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } - render( en[key], - } as unknown as AgentPresetRowProps)} />) - return actions -} - /** The runtime's own `{name}` substitution, so a test reads the shown text. */ function translate(key: keyof typeof en, params?: Record): string { const template = en[key] @@ -83,7 +65,7 @@ function renderLabel( ) { // The chip and the label read the same roster, metadata included. const store = createSnapshotStore({ - ...ROW_READY, options: SEAT_READY.options, ...roster, + ...ROSTER_READY, options: SEAT_READY.options, ...roster, }) const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } }) const load = vi.fn(() => Promise.resolve()) @@ -97,116 +79,6 @@ function renderLabel( return { load, view } } -describe('the General-settings row', () => { - it('reads the roster once and shows the current default', async () => { - const actions = renderRow() - - await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) - }) - - it('marks a locally authored option as local', () => { - renderRow() - - fireEvent.click(screen.getByRole('button')) - - // A local preset is exactly as privileged as the plugins it names, so the - // list says which rows are local rather than presenting all as vetted. - expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() - // The shipped one carries no marker; only local rows are called out. - expect(screen.getAllByText(en.presetStandardName)).toHaveLength(2) - }) - - it('falls back to the id for a preset that published no name', () => { - renderRow({ - currentValue: 'mine', - options: [ - { id: 'standard', trust: 'system', name: '标准模式' }, - { id: 'bare', trust: 'system' }, - { id: 'mine', trust: 'user' }, - { id: 'ours', trust: 'user', name: '团队模式' }, - ], - }) - - // The trigger names the preset; with no metadata the id is all there is. - expect(screen.getByRole('button').textContent).toContain('mine') - - fireEvent.click(screen.getByRole('button')) - - // A locally authored preset is marked whether or not it named itself. - expect(screen.getByText(`团队模式 · ${en.userTrust}`)).toBeTruthy() - expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() - // A shipped preset with no metadata is listed by id and carries no mark. - expect(screen.getByText('bare')).toBeTruthy() - }) - - it('shows the selected id until a stale roster contains it', () => { - renderRow({ currentValue: 'arriving', options: [] }) - - expect(screen.getByRole('button').textContent).toContain('arriving') - }) - - it('writes the picked preset and closes the menu', () => { - const actions = renderRow() - fireEvent.click(screen.getByRole('button')) - - fireEvent.click(screen.getByText(`mine · ${en.userTrust}`)) - - expect(actions.select).toHaveBeenCalledWith('mine') - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - }) - - it('closes on an outside dismissal', () => { - renderRow() - fireEvent.click(screen.getByRole('button')) - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - }) - - it('says it is loading before the roster answers', () => { - renderRow({ status: 'loading', currentValue: '' }) - - expect(screen.getByRole('button').textContent).toContain(en.loading) - expect(screen.getByRole('button')).toHaveProperty('disabled', true) - }) - - it('shows a failure in place of the description', () => { - renderRow({ error: 'roster unavailable' }) - - expect(screen.getByRole('alert').textContent).toBe('roster unavailable') - }) - - it('renders nothing when the deployment composes no presets', () => { - const { container } = render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - useAgentPreset: bindSnapshotSelector( - createSnapshotStore({ ...ROW_READY, status: 'unavailable', options: [] })), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetRowProps)} />) - - expect(container.firstChild).toBeNull() - }) - - it('closes and locks the menu when the settings turn read-only', () => { - const store = createSnapshotStore(ROW_READY) - render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - useAgentPreset: bindSnapshotSelector(store), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetRowProps)} />) - fireEvent.click(screen.getByRole('button')) - - act(() => { store.set({ ...ROW_READY, writable: false }) }) - - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(screen.getByRole('button')).toHaveProperty('disabled', true) - }) -}) - describe('the new-session chip', () => { it('reads the roster once and shows the staged preset by name', async () => { const actions = renderSeat() diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 4d8544dfcf..2b1022de62 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -1,29 +1,29 @@ /** - * The agent-preset settings controller: it derives both the options and the - * current default from one roster call, writes only the `default` field, and - * treats an empty roster as "this deployment composes no presets" rather than - * as a failure. + * The agent-preset roster store: it derives the display options from one + * roster call and treats an empty roster as "this deployment composes no + * presets" rather than as a failure. The default is written by the + * management section through `writeDefaultPreset`, which targets only the + * `default` field of the agent-presets namespace. */ import { describe, expect, it } from 'vitest' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' -import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, writeDefaultPreset, } from '../src/client/settings-store.ts' -/** The two faces the row reads: the roster Remote and the settings wire. */ +/** The two faces these tests drive: the roster Remote and the settings wire. */ interface FakeWire { api: SettingsWireFace remote: Pick } -/** Controller over a real mirror derived from the same fake wire. */ +/** The roster store over the fake wire's Remote face. */ function derivedController(wire: FakeWire) { - return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api)) + return new AgentPresetSettingsController(wire.remote) } import { AgentPresetSeatController } from '../src/client/seat-store.ts' @@ -59,17 +59,10 @@ function fakeApi( failWrite?: string failList?: string failWriteWith?: Error - readOnly?: boolean } = {}, ): FakeWire { const api = { settings: { - // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false - // and the row disables its control instead of offering a refused write. - describe: () => Promise.resolve({ - ok: true as const, - value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, - }), update: (ns: string, patch: { default?: unknown }) => { options.writes?.push({ ns, ops: patch }) if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) @@ -90,22 +83,8 @@ function fakeApi( } } -describe('the agent-preset settings controller', () => { - it('disables the control when this browser may not write settings', async () => { - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - ], { readOnly: true })) - - await controller.load() - - // The enabled `settings.describe` path reports a read-only provider; - // offering a control whose write answers `settings-rejected` would promise - // a switch the host refuses. - expect(controller.store.getSnapshot().writable).toBe(false) - expect(controller.store.getSnapshot().currentValue).toBe('standard') - }) - - it('derives options and the current default from one roster call', async () => { +describe('the agent-preset roster store', () => { + it('derives the display options from one roster call', async () => { const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'mine', trust: 'user', isDefault: false }, @@ -115,7 +94,6 @@ describe('the agent-preset settings controller', () => { const state = controller.store.getSnapshot() expect(state.status).toBe('ready') - expect(state.currentValue).toBe('standard') expect(state.options).toEqual([ { id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }, @@ -156,7 +134,7 @@ describe('the agent-preset settings controller', () => { await controller.load() // A deployment composing no presets is valid: every session shares the - // host composition and the row renders nothing. + // host composition and the surfaces render nothing. expect(controller.store.getSnapshot().status).toBe('unavailable') expect(controller.store.getSnapshot().error).toBeNull() }) @@ -175,48 +153,27 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: null, options: [] }) }) - it('writes only the default field, into the agent-presets namespace', async () => { + it('writeDefaultPreset writes only the default field, into the agent-presets namespace', async () => { const writes: Recorded[] = [] - const controller = derivedController(fakeApi([ + const wire = fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'minimal', trust: 'system', isDefault: false }, - ], { writes })) - await controller.load() + ], { writes }) - await controller.select('minimal') + expect(await writeDefaultPreset(wire.api, 'minimal')).toBeUndefined() expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, ops: { default: 'minimal' }, }]) - expect(controller.store.getSnapshot().currentValue).toBe('minimal') }) - it('restores the previous value and surfaces the message when the write fails', async () => { - const controller = derivedController(fakeApi([ + it('writeDefaultPreset surfaces the refusal message when the write fails', async () => { + const wire = fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, - { id: 'minimal', trust: 'system', isDefault: false }, - ], { failWrite: 'read-only settings' })) - await controller.load() + ], { failWrite: 'read-only settings' }) - await controller.select('minimal') - - const state = controller.store.getSnapshot() - expect(state.currentValue).toBe('standard') - expect(state.error).toBe('read-only settings') - expect(state.status).toBe('ready') - }) - - it('ignores a pick that is already the default', async () => { - const writes: Recorded[] = [] - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - ], { writes })) - await controller.load() - - await controller.select('standard') - - expect(writes).toEqual([]) + expect(await writeDefaultPreset(wire.api, 'minimal')).toBe('read-only settings') }) it('surfaces a roster failure without claiming the deployment has no presets', async () => { @@ -229,19 +186,6 @@ describe('the agent-preset settings controller', () => { expect(state.error).toBe('host down') }) - it('shows the first preset when the roster marks none default', async () => { - // Settings can name a preset that was since deleted; the picker still has - // to show something rather than an empty control. - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: false }, - { id: 'mine', trust: 'user', isDefault: false }, - ])) - - await controller.load() - - expect(controller.store.getSnapshot().currentValue).toBe('standard') - }) - it('ignores a load while one is already in flight', async () => { const writes: Recorded[] = [] const controller = derivedController(fakeApi( @@ -270,18 +214,15 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) }) - it('reports a transport that rejects mid-write and keeps the old default showing', async () => { - const controller = derivedController(fakeApi([ + it('writeDefaultPreset reports a transport that rejects mid-write', async () => { + const wire = fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'mine', trust: 'user', isDefault: false }, - ], { failWriteWith: new Error('socket closed') })) - await controller.load() + ], { failWriteWith: new Error('socket closed') }) - await controller.select('mine') - - // The value snaps back because the host never took it; a picker still - // showing "mine" would be claiming a default that does not exist. - expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) + // The caller must be able to say the host never took the value; a surface + // still showing "mine" would be claiming a default that does not exist. + expect(await writeDefaultPreset(wire.api, 'mine')).toBe('socket closed') }) }) @@ -567,22 +508,4 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot().error).toBe('socket closed') }) - it('degrades to a read-only row while the mirror holds no answer', async () => { - const controller = derivedController({ - // The roster answered; the mirror's read is what failed, so the row - // shows the current default without offering a write it never confirmed. - api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace, - remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]), - }) - - await controller.load() - - expect(controller.store.getSnapshot()).toMatchObject({ - status: 'ready', - writable: false, - currentValue: 'standard', - }) - }) - - }) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 198b22aa97..f1a56b6c18 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/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-plugin-inventory/README.md -README.md: c44e150e528f20349429f9a49d416367f1ca3c41 -README.zh.md: ae2e2cd1105464f0fb262a07e96b66082a6e7166 +README.md: bc89b60e89e0fe257c6f422a2749bd4188fe796c +README.zh.md: 13c09aaeb108120ba22f05d9828da75088a7deb8 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index c44e150e52..bc89b60e89 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -1,5 +1,5 @@ --- -description: "Read-only Cordis Loader inventory tab in Web Plugins settings for the dsh web client: searchable plugin catalog with enablement state and configuration." +description: "Scope-grouped read-only plugin inventory tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders a searchable two-column catalog of compact disclosure cards: each collapsed card shows the short module name, an effective-enablement tag, and (for enabled entries) a colored root-fiber status dot; expanding a card reveals the Loader-tree entry id, effective configuration, and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. +`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders the inventory in two groups. The agent-preset group comes first: a display-only switcher over the roster opens on the default preset, and each composition row is a compact disclosure card carrying its enablement — including `conditional` for a disabled gate only a mount can decide — with provenance facts behind the disclosure. The global group follows collapsed, its header carrying the entry count and a failure count; expanded, failures float first, and entries disabled globally but enabled by presets fold into a session-plugins drawer naming their providers instead of reading as plainly disabled. Search filters both groups, forces the collapsed disclosures open, and points at matches sitting in unselected presets. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details; without a roster the tab renders the global plane alone, expanded. ## Table of Contents @@ -29,7 +29,11 @@ Open the Plugins section in Settings and select the **Plugin list** tab to inspe ### Reading a card -Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id, followed by the effective configuration and, for enabled entries, Cordis status; disabled entries omit the redundant unmounted runtime state. Search filters the catalog by name and entry id. +Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its enable condition when it carries one; a session-plugins drawer row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Search filters both groups by module name and entry id. + +### The preset switcher + +The switcher lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen. ### Retrying a failed read @@ -51,7 +55,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. +Row keys are scope-qualified (`global:`, `drawer:`, `preset::`), so one module appearing in several scopes keeps distinct disclosure state; an entry id is shown as detail only when the row declares one and is never classified by string shape. The session-plugins drawer is derived client-side: a global entry joins it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. @@ -86,7 +90,7 @@ None; this package neither assembles nor sends a provider request. These limits define the freshness and reach of the inventory view; they are current package constraints. - **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one. -- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. +- **Read-only in both planes** — the tab shows global and preset enablement but mutates neither; enable/disable controls that write a custom preset's own composition file are deliberate follow-up work. ### Dev Note diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index ae2e2cd110..13c09aaeb1 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端设置中的只读 Cordis Loader 清单标签页:可搜索的插件目录,含启停状态与配置。" +description: "dsh Web 客户端设置中按作用域分组的只读插件清单标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并以可搜索的双列紧凑折叠卡片展示清单:每张收起的卡片显示模块短名称、有效启停标签,以及(对已启用条目)彩色根 fiber 状态圆点;展开卡片会显示 Loader 树条目 id、有效配置与 Cordis 状态。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节。 +`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并把清单分成两组渲染。Agent 预设组在前:一个只改显示的切换器覆盖 roster、初始停在默认预设,每个组合行是一张紧凑折叠卡片,携带其启停状态——含只有挂载才能裁决的 disabled 门对应的 `conditional`——出处事实收在折叠里。全局组随后且默认收起,组头带条目计数与失败计数;展开后失败行浮在最前,全局停用但被预设启用的条目收进「会话插件」抽屉并列出提供它的预设,而不是读作单纯的已停用。搜索同时过滤两组、强制撑开收起的折叠,并指出未选中预设里的匹配。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节;没有 roster 时标签页只渲染全局平面并保持展开。 ## 目录 @@ -29,7 +29,11 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用模块短名称作为标题,并以小标签表示有效启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会直接展示 Loader 树条目 id、有效配置,已启用条目还会显示 Cordis 状态;已停用条目省略重复的「未挂载」运行状态。搜索按名称与条目 id 过滤目录。 +每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的启用条件;「会话插件」抽屉行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。搜索按模块名称与条目 id 过滤两组。 + +### 预设切换器 + +切换器列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选默认预设或某个会话的预设仍在原处:Agent 预设分区与新会话页。 ### 重试失败的读取 @@ -51,7 +55,7 @@ kind: "package-reference" ### 渲染 -条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标;代码不按字符串形状对它分类。 +行 key 按作用域限定(`global:`、`drawer:`、`preset::`),因此同一模块出现在多个作用域时保持各自的展开状态;条目 id 只在行声明了它时作为详情展示,代码不按字符串形状对它分类。「会话插件」抽屉在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才归入抽屉,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 @@ -86,7 +90,7 @@ kind: "package-reference" 这些限制定义清单视图的新鲜度与触达范围;它们是当前包约束。 - **每次 Settings 挂载或重试只读取一份快照**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。 -- **只读 Loader 视图**:本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 +- **两个平面都只读**:标签页展示全局与预设的启停状态但都不修改;写回自定义预设组合文件的启停控件是刻意留作后续的工作。 ### 开发备注 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index de10a3bd36..41c2d7ef59 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -201,11 +201,181 @@ white-space: nowrap; } -.configTag[data-enabled='true'] { +.configTag[data-kind='enabled'] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); } +.configTag[data-kind='preset'] { + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); + color: var(--dsw-alias-state-business-primary); +} + +.configTag[data-kind='conditional'] { + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b45309) 12%, transparent); + color: var(--dsw-alias-state-warning-primary, #b45309); +} + +.configTag[data-kind='failed'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.group { + display: flex; + flex-direction: column; + gap: 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + padding: 10px 12px 12px; +} + +.groupHeader, +.groupToggle { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} + +.groupToggle { + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.groupToggle:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.groupToggle > .chevron { + transform: rotate(-90deg); +} + +.groupToggle[aria-expanded='true'] > .chevron { + transform: none; +} + +.groupTitle { + font-size: 13px; + line-height: 20px; + font-weight: 600; +} + +.groupSubtitle { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.groupCount { + margin-left: auto; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} + +.failedCount { + flex: none; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; +} + +.switcher { + max-width: 60%; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 3px 8px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + font-weight: 600; +} + +.badge { + flex: none; + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 16px; +} + +.badge[data-kind='default'] { + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.badge[data-kind='broken'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.brokenNote { + margin: 0; + border-radius: 8px; + padding: 8px 10px; + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); + color: var(--dsw-alias-state-error-primary); + font-size: 12.5px; + line-height: 18px; + overflow-wrap: anywhere; + white-space: pre-line; +} + +.hint { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12.5px; + line-height: 18px; +} + +.jumpLink { + border: 0; + padding: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.drawer { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; + border: 1px dashed var(--dsw-alias-border-l1); + border-radius: 10px; + padding: 8px 10px 10px; +} + +.enabledIn { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + +.card[data-failed='true'] { + border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); +} + .chevron { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 12fd3d2a7a..7726a9b1ab 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -15,6 +15,8 @@ export interface PluginInventorySettingsTabInjected { } type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] +type AgentPresetGroup = NonNullable[number] +type AgentPresetRow = AgentPresetGroup['rows'][number] type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] /** Full component props assembled by the Settings slot renderer. */ @@ -23,6 +25,8 @@ export type PluginInventorySettingsTabProps = & PropsLocale<'settings.pluginInventory'> & InjectFace +type Translate = PluginInventorySettingsTabProps['t'] + type ViewState = | { readonly status: 'loading' } | { readonly status: 'error' } @@ -37,10 +41,7 @@ const PHASE_KEYS = { } satisfies Record, PluginInventoryLocaleKey> /** Localized accessible label for one root Fiber phase. */ -function phaseLabel( - phase: PluginFiberPhase, - t: PluginInventorySettingsTabProps['t'], -): string { +function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) } @@ -53,19 +54,121 @@ function moduleShortName(moduleName: string): string { .replace(/^dsh-(?:host-|client-)?/, '') } -/** Whether an inventory row matches the local catalog query. */ -function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { +/** Whether one row's module name or entry id matches the catalog query. */ +function matches(moduleName: string, entryId: string | null, normalizedQuery: string): boolean { if (normalizedQuery.length === 0) return true - return [entry.moduleName, entry.entryId] + return [moduleName, ...entryId === null ? [] : [entryId]] .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) } -/** Render the read-only current Loader inventory. */ +/** The roster row shown when the preset switcher has no explicit choice. */ +function fallbackPreset(presets: readonly AgentPresetGroup[]): AgentPresetGroup | undefined { + return presets.find(preset => preset.isDefault) ?? presets[0] +} + +/** The switcher's display label for one preset. */ +function presetLabel(preset: AgentPresetGroup, t: Translate): string { + const name = preset.name ?? preset.id + if (preset.broken !== undefined) return t('presetOptionBroken', { name }) + if (preset.isDefault) return t('presetOptionDefault', { name }) + return name +} + +/** One expandable plugin card; the caller owns the trailing status content. */ +function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: { + readonly rowKey: string + readonly moduleName: string + readonly entryId: string | null + readonly trailing: ReactNode + readonly ariaLabel: string + readonly failed: boolean + readonly expanded: string | null + readonly onToggle: (key: string) => void + readonly children: ReactNode +}): ReactNode { + const open = expanded === rowKey + const detailId = `plugin-details-${encodeURIComponent(rowKey)}` + return ( +
  • + + {open ?
    {children}
    : null} +
  • + ) +} + +/** Detail rows shared by every card: the Loader identity, then labeled facts. */ +function CardFacts({ moduleName, moduleLabel, entryId, facts }: { + readonly moduleName: string + readonly moduleLabel: string + readonly entryId: string | null + readonly facts: readonly (readonly [label: string, value: ReactNode])[] +}): ReactNode { + return ( + <> + {entryId === null ? null : {entryId}} +
    +
    +
    {moduleLabel}
    +
    {moduleName}
    +
    + {facts.map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + + ) +} + +/** Status dot naming the root-fiber phase. */ +function PhaseDot({ phase, t }: { readonly phase: PluginFiberPhase; readonly t: Translate }): ReactNode { + const status = phaseLabel(phase, t) + return ( + + ) +} + +/** Enablement tag; `kind` selects the palette. */ +function StateTag({ kind, label }: { readonly kind: string; readonly label: string }): ReactNode { + return {label} +} + +/** Render the read-only plugin inventory: agent presets first, then the global plane. */ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsTabProps): ReactNode { - const catalogId = useId() + const sectionId = useId() const [request, setRequest] = useState(0) const [query, setQuery] = useState('') - const [expanded, setExpanded] = useState(null) + const [expanded, setExpanded] = useState(null) + const [chosenPreset, setChosenPreset] = useState(null) + const [globalOpen, setGlobalOpen] = useState(null) + const [drawerOpen, setDrawerOpen] = useState(false) const [state, setState] = useState({ status: 'loading' }) useEffect(() => { @@ -78,23 +181,160 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT }, [list, request]) const normalizedQuery = query.trim().toLocaleLowerCase() - const filteredEntries = useMemo( - () => state.status === 'ready' - ? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery)) - : [], - [normalizedQuery, state], - ) + const searching = normalizedQuery.length > 0 + const snapshot = state.status === 'ready' ? state.snapshot : undefined + const presets = snapshot?.agentPresets ?? [] + const selected = presets.find(preset => preset.id === chosenPreset) ?? fallbackPreset(presets) - useEffect(() => { - if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) { - setExpanded(null) + /** Presets that actually enable a module, keyed by module name. */ + const enabledIn = useMemo(() => { + const found = new Map() + for (const preset of presets) { + for (const row of preset.rows) { + if (row.enabled !== true) continue + const groups = found.get(row.moduleName) + if (groups === undefined) found.set(row.moduleName, [preset]) + else if (!groups.includes(preset)) groups.push(preset) + } } - }, [expanded, filteredEntries]) + return found + }, [presets]) + + const entries = snapshot?.entries ?? [] + const failedEntries: PluginInventoryEntry[] = [] + const drawerEntries: { entry: PluginInventoryEntry; providers: readonly [AgentPresetGroup, ...AgentPresetGroup[]] }[] = [] + const regularEntries: PluginInventoryEntry[] = [] + for (const entry of entries) { + const providers = enabledIn.get(entry.moduleName) + if (entry.fiberPhase === 'failed') failedEntries.push(entry) + else if (!entry.enabled && providers !== undefined) drawerEntries.push({ entry, providers }) + else regularEntries.push(entry) + } + + const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery) + const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery) + const filteredFailed = failedEntries.filter(entryMatch) + const filteredDrawer = drawerEntries.filter(drawerRow => entryMatch(drawerRow.entry)) + const filteredRegular = regularEntries.filter(entryMatch) + const globalCount = filteredFailed.length + filteredDrawer.length + filteredRegular.length + const selectedRows = selected === undefined ? [] : selected.rows.filter(rowMatch) + const otherPresetMatches = searching + ? presets.filter(preset => preset !== selected && preset.rows.some(rowMatch)) + : [] + const otherMatchCount = otherPresetMatches + .reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0) + + const globalEffectiveOpen = searching || (globalOpen ?? presets.length === 0) + const drawerEffectiveOpen = searching || drawerOpen + const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0 + && otherPresetMatches.length === 0 const retry = (): void => { setState({ status: 'loading' }) setRequest(value => value + 1) } + const toggleRow = (key: string): void => { + setExpanded(current => current === key ? null : key) + } + + /** Trailing status and detail facts for one row of the selected preset. */ + const presetRowCard = (preset: AgentPresetGroup, row: AgentPresetRow, index: number): ReactNode => { + const key = `preset:${preset.id}:${String(index)}` + const title = moduleShortName(row.moduleName) + const failed = row.fiberPhase === 'failed' + const stateText = failed + ? t('failedTag') + : row.enabled === true ? t('enabledTag') : row.enabled === false ? t('disabledTag') : t('conditionalTag') + const kind = failed ? 'failed' : row.enabled === true ? 'enabled' : row.enabled === false ? 'disabled' : 'conditional' + return ( + + {row.enabled === true && !failed ? : null} + + + )} + > + {row.condition}
    ] as const], + ]} + /> + + ) + } + + /** One global-plane row; a drawer row carries the presets that enable it. */ + const globalRowCard = ( + entry: PluginInventoryEntry, + providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]], + ): ReactNode => { + const key = `${providers === undefined ? 'global' : 'drawer'}:${entry.entryId}` + const title = moduleShortName(entry.moduleName) + const failed = entry.fiberPhase === 'failed' + const stateText = failed + ? t('failedTag') + : providers !== undefined ? t('presetEnabledTag') : t(entry.enabled ? 'enabledTag' : 'disabledTag') + const kind = failed ? 'failed' : providers !== undefined ? 'preset' : entry.enabled ? 'enabled' : 'disabled' + return ( + + {entry.enabled && !failed ? : null} + + + )} + > + + {providers.map(preset => preset.name ?? preset.id).join(' · ')} + + + )], + ] + : [ + [t('configuration'), t(entry.enabled ? 'enabledTag' : 'disabledTag')], + ...entry.enabled ? [[t('runtime'), phaseLabel(entry.fiberPhase, t)] as const] : [], + ]} + /> + + ) + } return (
    @@ -105,7 +345,7 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
    ) : null} - {state.status === 'ready' ? ( + {snapshot !== undefined ? (
    -
    -

    {t('catalog')}

    - {filteredEntries.length} -
    - {state.snapshot.entries.length === 0 ?

    {t('empty')}

    : null} - {state.snapshot.entries.length > 0 && filteredEntries.length === 0 - ?

    {t('emptySearch')}

    - : null} - {filteredEntries.length > 0 ? ( -
      - {filteredEntries.map((entry) => { - const status = phaseLabel(entry.fiberPhase, t) - const title = moduleShortName(entry.moduleName) - const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag') - const open = expanded === entry.entryId - const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}` - return ( -
    • + {entries.length === 0 && presets.length === 0 ?

      {t('empty')}

      : null} + {nothingMatches ?

      {t('emptySearch')}

      : null} + + {selected !== undefined ? ( +
      +
      + + {t('presetSubtitle')} + + {selectedRows.length} + +
      + {selected.broken !== undefined ? ( +

      {selected.broken}

      + ) : null} + {selectedRows.length > 0 ? ( +
        + {selectedRows.map((row, index) => presetRowCard(selected, row, index))} +
      + ) : null} + {otherMatchCount > 0 ? ( +

      + {t('matchesInOtherPresets', { count: String(otherMatchCount) })} + {otherPresetMatches.map(preset => ( - {open ? ( -

      - {entry.entryId} -
      -
      -
      {t('configuration')}
      -
      {configuration}
      -
      - {entry.enabled ? ( -
      -
      {t('cordis')}
      -
      {status}
      -
      - ) : null} -
      -
      - ) : null} -
    • - ) - })} -
    + ))} +

    + ) : null} + + ) : null} + + {entries.length > 0 ? ( +
    + + {globalEffectiveOpen ? ( +
    + {filteredFailed.length + filteredRegular.length > 0 ? ( +
      + {filteredFailed.map(entry => globalRowCard(entry))} + {filteredRegular.map(entry => globalRowCard(entry))} +
    + ) : null} + {drawerEntries.length > 0 ? ( +
    + + {drawerEffectiveOpen && filteredDrawer.length > 0 ? ( +
      + {filteredDrawer.map(drawerRow => globalRowCard(drawerRow.entry, drawerRow.providers))} +
    + ) : null} +
    + ) : null} +
    + ) : null} +
    ) : null}
    ) : null} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 866937016c..3857204a93 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -7,18 +7,36 @@ export const zh = { error: '暂时无法读取插件。', retry: '重试', search: '搜索插件', - catalog: '插件列表', empty: '暂无插件。', emptySearch: '没有匹配的插件。', + presetSubtitle: '会话使用的插件', + switcherLabel: '选择要查看的 Agent 预设', + presetOptionDefault: '{name}(默认)', + presetOptionBroken: '{name}(加载失败)', + globalTitle: '全局', + globalSubtitle: '系统与所有会话共用', + drawerTitle: '会话插件', + drawerSubtitle: '不在全局运行,由 Agent 预设按会话提供', + drawerDetail: '全局已停用,由 Agent 预设按会话提供', + enabledIn: '启用于', + viewInPreset: '去预设分组查看', + matchesInOtherPresets: '其他预设中还有 {count} 个匹配:', + failedCountLabel: '个失败', enabledTag: '已启用', disabledTag: '已停用', + conditionalTag: '条件启用', + presetEnabledTag: '预设中启用', + failedTag: '启动失败', + moduleLabel: '完整名称', + fromPreset: '来自', + condition: '启用条件', configuration: '配置状态', - cordis: 'Cordis 状态', - unobserved: '未挂载', + runtime: '运行状态', + unobserved: '未运行', pending: '等待依赖', loadingPhase: '加载中', - active: '已挂载', - failed: '挂载失败', + active: '运行中', + failed: '启动失败', unloading: '卸载中', } satisfies Record @@ -32,17 +50,35 @@ export const en = { error: 'Plugins are temporarily unavailable.', retry: 'Retry', search: 'Search plugins', - catalog: 'Plugin list', empty: 'No plugins are available.', emptySearch: 'No matching plugins.', + presetSubtitle: 'Plugins your sessions run', + switcherLabel: 'Choose the agent preset to inspect', + presetOptionDefault: '{name} (default)', + presetOptionBroken: '{name} (failed to load)', + globalTitle: 'Global', + globalSubtitle: 'Shared by the system and every session', + drawerTitle: 'Session plugins', + drawerSubtitle: 'Not running globally; agent presets provide them per session', + drawerDetail: 'Disabled globally; agent presets provide it per session', + enabledIn: 'Enabled in', + viewInPreset: 'View in the preset group', + matchesInOtherPresets: '{count} more matches in other presets: ', + failedCountLabel: 'failed', enabledTag: 'Enabled', disabledTag: 'Disabled', + conditionalTag: 'Conditional', + presetEnabledTag: 'Enabled via presets', + failedTag: 'Failed', + moduleLabel: 'Module', + fromPreset: 'From', + condition: 'Enable condition', configuration: 'Configuration', - cordis: 'Cordis status', - unobserved: 'Not mounted', + runtime: 'Status', + unobserved: 'Not running', pending: 'Waiting for dependencies', loadingPhase: 'Loading', - active: 'Mounted', - failed: 'Mount failed', + active: 'Running', + failed: 'Failed to start', unloading: 'Unloading', } satisfies Record diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index bec1d64706..3310d87e6d 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -11,7 +11,11 @@ import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts' afterEach(cleanup) type Snapshot = Awaited> -const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginInventorySettingsTabProps['t'] +const t = ((key: PluginInventoryLocaleKey, params?: Record): string => + Object.entries(params ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, value), + en[key], + )) as PluginInventorySettingsTabProps['t'] function props(list: PluginInventorySettingsTabInjected['list']): PluginInventorySettingsTabProps { return { @@ -20,79 +24,234 @@ function props(list: PluginInventorySettingsTabInjected['list']): PluginInventor } as PluginInventorySettingsTabProps } +/** A deployment with a roster: one failed global row, two preset-provided rows. */ const SNAPSHOT = { entries: [ + { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' }, + { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' }, { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, - { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' }, - { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, - { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, - { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null }, - { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null }, + { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null }, + { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null }, + { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null }, + ], + agentPresets: [ + { + id: 'standard', + name: '标准模式', + isDefault: true, + rows: [ + { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active' }, + { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: true, fiberPhase: null }, + { + entryId: 'pwsh', + moduleName: '@fixture/pwsh', + enabled: 'conditional', + condition: 'process.platform === \'win32\'', + fiberPhase: null, + }, + { entryId: 'codex', moduleName: '@fixture/codex', enabled: false, fiberPhase: null }, + { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed' }, + { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null }, + ], + }, + { + id: 'ptc', + isDefault: false, + rows: [ + { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null }, + { entryId: 'bash-fork', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null }, + { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: 'conditional', fiberPhase: null }, + ], + }, + { id: 'shattered', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] }, ], } as unknown as Snapshot +async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise> { + const view = render( snapshot)} />) + await screen.findByRole('searchbox', { name: en.search }) + return view +} + +const globalToggle = (): HTMLElement => + screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) }) +const drawerToggle = (): HTMLElement => + screen.getByRole('button', { name: (name: string) => name.startsWith(en.drawerTitle) }) + describe('PluginInventorySettingsTab', () => { - it('renders runtime status only for enabled plugins', async () => { - const deferred = Promise.withResolvers() - const list = vi.fn(() => deferred.promise) - const view = render() - expect(screen.getByText(en.loading)).toBeTruthy() + it('shows the default preset first and keeps the global plane collapsed', async () => { + const view = await renderReady() - await act(async () => { deferred.resolve(SNAPSHOT) }) - expect(list).toHaveBeenCalledOnce() - expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() - expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') - expect(screen.getAllByRole('listitem')).toHaveLength(7) - expect(screen.getAllByText(en.enabledTag)).toHaveLength(6) + const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + expect((switcher as HTMLSelectElement).value).toBe('standard') + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([ + '标准模式 (default)', + 'ptc', + '坏预设 (failed to load)', + ]) + expect(screen.getByText(en.presetSubtitle)).toBeTruthy() + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6') + + // Only the preset group lists rows while the global plane stays collapsed. + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(3) + expect(screen.getByText(en.conditionalTag)).toBeTruthy() expect(screen.getByText(en.disabledTag)).toBeTruthy() - for (const value of [ - 'Mounted', - 'Waiting for dependencies', - 'Loading', - 'Mount failed', - 'Unloading', - 'Not mounted', - ]) { - expect(screen.getByRole('img', { name: value })).toBeTruthy() - } - const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) - expect(active.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(active) - expect(active.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d') - expect(screen.getByText(en.configuration)).toBeTruthy() - expect(screen.getByText(en.cordis)).toBeTruthy() - fireEvent.click(active) - expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + expect(screen.getByText(en.failedTag)).toBeTruthy() + expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy() + expect(screen.getAllByRole('img', { name: 'Not running' })).toHaveLength(2) - fireEvent.click(active) - fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { - target: { value: 'disabled-entry' }, - }) + expect(globalToggle().getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') + expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy() + + // A preset row expands into its provenance facts. + fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' })) + expect(screen.getByText(en.fromPreset)).toBeTruthy() + expect(screen.getByText('标准模式')).toBeTruthy() + expect(screen.getByText(en.condition)).toBeTruthy() + expect(screen.getByText('process.platform === \'win32\'')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' })) + expect(screen.queryByText(en.condition)).toBeNull() + + // A failed preset row names its runtime state instead of a condition. + fireEvent.click(screen.getByRole('button', { name: 'crashy, Failed' })) + expect(screen.getByText(en.runtime)).toBeTruthy() + expect(screen.getByText('Failed to start')).toBeTruthy() + + // A row declaring no id has no Loader identity line, only its module. + fireEvent.click(screen.getByRole('button', { name: 'anonymous, Enabled' })) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' })) - expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) - expect(screen.queryByText(en.cordis)).toBeNull() - expect(screen.queryByText(en.unobserved)).toBeNull() + expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous') }) - it('filters by module name or Loader entry id', async () => { - render( SNAPSHOT)} />) - const search = await screen.findByRole('searchbox', { name: en.search }) + it('expands the global plane with failures first and the session-plugin drawer', async () => { + const view = await renderReady() - fireEvent.change(search, { target: { value: 'disabled-entry' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('directory-picker-native')).toBeTruthy() + fireEvent.click(globalToggle()) + expect(globalToggle().getAttribute('aria-expanded')).toBe('true') + const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]') + expect(failed?.getAttribute('data-plugin-entry')).toBe('telemetry') + // Failures float above the Loader-ordered remainder. + expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed) - fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('hmr')).toBeTruthy() + // The drawer stays collapsed until opened, then names its providers. + expect(drawerToggle().getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(en.presetEnabledTag)).toBeNull() + fireEvent.click(drawerToggle()) + expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2) + + fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' })) + expect(screen.getByText(en.drawerDetail)).toBeTruthy() + expect(screen.getByText(en.enabledIn)).toBeTruthy() + expect(screen.getByText('标准模式 · ptc')).toBeTruthy() + + // The failed global card reports its runtime state. + fireEvent.click(screen.getByRole('button', { name: 'telemetry, Failed' })) + expect(screen.getByText('Failed to start')).toBeTruthy() + + // A disabled row outside every preset stays plainly disabled. + fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' })) + expect(screen.queryByText(en.drawerDetail)).toBeNull() + + fireEvent.click(drawerToggle()) + expect(screen.queryByText(en.presetEnabledTag)).toBeNull() + fireEvent.click(globalToggle()) + expect(globalToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it('switches the inspected preset in place, including broken ones', async () => { + const view = await renderReady() + const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + + fireEvent.change(switcher, { target: { value: 'ptc' } }) + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('3') + fireEvent.click(screen.getAllByRole('button', { name: 'tool-bash, Enabled' })[0]!) + // An unnamed preset labels provenance by its id. + expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc') + + fireEvent.change(switcher, { target: { value: 'shattered' } }) + expect(screen.getByRole('alert').textContent).toBe('the composition file is missing') + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') + }) + + it('jumps from a drawer row to the preset that enables it', async () => { + await renderReady() + const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + fireEvent.change(switcher, { target: { value: 'ptc' } }) + + fireEvent.click(globalToggle()) + fireEvent.click(drawerToggle()) + fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' })) + fireEvent.click(screen.getByRole('button', { name: en.viewInPreset })) + expect((switcher as HTMLSelectElement).value).toBe('standard') + }) + + it('searches across scopes and points at matches in other presets', async () => { + const view = await renderReady() + const search = screen.getByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'tool-bash' } }) + // Searching forces the collapsed global plane and drawer open. + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('1') + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('1') + expect(screen.getByText(en.presetEnabledTag)).toBeTruthy() + expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull() + const hint = screen.getByText((text: string) => text.startsWith('2 more matches')) + expect(hint).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'ptc' })) + expect(screen.getByRole('combobox', { name: en.switcherLabel }).value).toBe('ptc') + + // A match visible only in another preset keeps the pointer without rows. + fireEvent.change(search, { target: { value: 'crashy' } }) + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') + expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy() + expect(screen.queryByText(en.emptySearch)).toBeNull() + + // A match on a Loader entry id only reaches the global plane. + fireEvent.change(search, { target: { value: '8a1b2c3d' } }) + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('1') + expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull() fireEvent.change(search, { target: { value: 'not-a-plugin' } }) - expect(screen.queryAllByRole('listitem')).toHaveLength(0) expect(screen.getByText(en.emptySearch)).toBeTruthy() + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + }) + + it('renders a rosterless deployment as one expanded global list', async () => { + const view = await renderReady({ + entries: [ + { entryId: 'hmr', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, + { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null }, + ], + } as unknown as Snapshot) + + expect(screen.queryByRole('combobox', { name: en.switcherLabel })).toBeNull() + expect(globalToggle().getAttribute('aria-expanded')).toBe('true') + expect(screen.getAllByRole('listitem')).toHaveLength(2) + + fireEvent.click(screen.getByRole('button', { name: 'hmr, Enabled' })) + expect(screen.getByText(en.runtime)).toBeTruthy() + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('hmr') + fireEvent.click(screen.getByRole('button', { name: 'off, Disabled' })) + expect(screen.getAllByText(en.moduleLabel).length).toBeGreaterThan(0) + expect(screen.queryByText(en.runtime)).toBeNull() + }) + + it('renders a preset-only snapshot without the global section', async () => { + await renderReady({ + entries: [], + agentPresets: [{ + id: 'solo', + isDefault: false, + rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }], + }], + }) + + expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull() + expect(screen.queryByText(en.empty)).toBeNull() + expect(screen.getAllByRole('listitem')).toHaveLength(1) }) it('shows a generic failure and retries into the empty state', async () => { @@ -116,6 +275,7 @@ describe('PluginInventorySettingsTab', () => { const deferred = Promise.withResolvers() const pending = render( deferred.promise)} />) + expect(screen.getByText(en.loading)).toBeTruthy() pending.unmount() await act(async () => { deferred.resolve(SNAPSHOT) }) 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 cb8f1a7ebc..939c9abaca 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1526,7 +1526,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'settings.section\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-locale LanguageRow id \'language\'', - 'client-ui-agent-preset AgentPresetRow id \'agent-preset\'', 'client-ui-chat TranscriptViewRow id \'transcript-view\'', 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8edd7adb9d..c68d5dd823 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -147,6 +147,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'the rows and the authoring capability.', }, + { + signature: 'async compositionInventory(): Promise', + description: 'Every preset\'s composition as flattened plugin rows, for plugin-listing surfaces beside the roster\'s own picker.\n\nA preset with a live standing mount answers from its newest generation\'s Loader entries — the composition new sessions join — and one never composed since boot answers from its file, with `!!js` disabled gates evaluated against the Loader context so both answers reflect the same host. Reading never mounts: an unmounted preset is parsed, not composed, so listing a preset\'s plugins cannot activate them early. A composition that stopped reading between discovery\'s health verdict and this read is reported broken with the raced reason rather than dropped.', + parameters: [], + returns: 'one composition per roster preset, in roster order.', + }, { signature: 'async resolve(id?: string): Promise', description: 'Resolve one preset by id.\n\nA broken preset resolves — deleting one, reading one, and reporting one all need the row — and the mounting paths refuse it AFTER resolution through resolveMountable.', @@ -3410,6 +3416,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPreset', declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', }, + { + name: 'AgentPresetComposition', + declaration: 'export interface AgentPresetComposition {\n readonly id: string;\n readonly name?: string;\n readonly isDefault: boolean;\n readonly broken?: string;\n readonly rows: readonly AgentPresetCompositionRow[];\n}', + }, + { + name: 'AgentPresetCompositionRow', + declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', + }, { name: 'AgentPresetDirectoryOpenValue', declaration: 'export type AgentPresetDirectoryOpenValue = {\n readonly opened: true;\n} | {\n readonly opened: false;\n readonly path: string;\n};', @@ -3674,6 +3688,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactionTrigger', declaration: 'export type CompactionTrigger = \'pressure\' | \'context-overflow\';', }, + { + name: 'CompositionRowEnablement', + declaration: 'export type CompositionRowEnablement = boolean | \'conditional\';', + }, { name: 'ConfinedArgv', declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}', @@ -3966,6 +3984,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', }, + { + name: 'FiberState', + declaration: 'export type FiberState = FiberStateEnum;', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 93797f6893..bdb058c1cf 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/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/host/plugin-inventory/README.md -README.md: 3f982ebcfdc85f3abd81d1615efccbec6b6bbbed -README.zh.md: eab5409c230ce64f2da66e267c0e203b709daa91 +README.md: 84b437b9952e06bf5231515d15c8fa6419b990be +README.zh.md: 6dacf80788ad7641bea162624e608f340dac1dca diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 3f982ebcfd..84b437b995 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -1,5 +1,5 @@ --- -description: "Read-only projection of the current Cordis Loader plugin state: the pluginInventory service and its pluginInventory/list Remote for web GUI host clients." +description: "Read-only projection of the current Cordis Loader plugin state with each agent preset's composition beside it: the pluginInventory service and its pluginInventory/list Remote for web GUI host clients." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Clients and settings pages can show what is currently composed in the host: calling `pluginInventory/list` returns the current non-group Loader entries in Loader order — entry id, module specifier, effective enablement, and root Fiber phase (`pending`, `loading`, `active`, `failed`, or `unloading`, or `null` when an entry has no live root Fiber). The snapshot is point-in-time: the Loader is the sole lifecycle authority, and this package owns no cache, history, provenance model, event stream, or mutation path. Client packages consume the Remote through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. +Clients and settings pages can show what is currently composed in the host: calling `pluginInventory/list` returns the current non-group Loader entries in Loader order — entry id, module specifier, effective enablement, and root Fiber phase (`pending`, `loading`, `active`, `failed`, or `unloading`, or `null` when an entry has no live root Fiber). When an agent-preset roster is composed, the snapshot also carries one group per preset — id, display name, default marking, health, and flattened composition rows — because a deployment that mounts the roster runs its model-facing plugins there rather than on the Loader's own entries. The snapshot is point-in-time: the Loader is the sole lifecycle authority, and this package owns no cache, history, provenance model, event stream, or mutation path. Client packages consume the Remote through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. ## Table of Contents @@ -25,12 +25,16 @@ Clients and settings pages can show what is currently composed in the host: call ## Use this package -Call `pluginInventory/list` when a client or settings page needs to show what is currently composed in the host — which plugins are loaded, enabled, and alive. The Remote is the only entry point: the service is Remote-only and deliberately declares no same-process Cordis `Context` merge. +Call `pluginInventory/list` when a client or settings page needs to show what is currently composed in the host — which plugins are loaded, enabled, and alive, and what each agent preset would give a session. The Remote is the only entry point: the service is Remote-only and deliberately declares no same-process Cordis `Context` merge. ### What a snapshot contains Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped. +### Per-preset compositions + +With a roster composed, `agentPresets` carries one group per preset in roster order: its id, published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset stays listed with its reason and no rows. Without a roster the field is absent. + ### What you can and cannot do with it The inventory is a snapshot for display and diagnostics: a client can render the roster, flag failed entries, and detect changes by comparing snapshots. It cannot enable, disable, add, or remove plugins, and it carries no history — a fiber that already failed and was removed is absent. Because the service reads the Loader on every call, the answer always reflects the current composition rather than a cached view. @@ -45,7 +49,7 @@ The inventory is a snapshot for display and diagnostics: a client can render the ### Design concept -The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal plugin/status events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. +The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal plugin/status events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. The agent-preset roster is an optional peer resolved per call through `ctx.get('agentPresets')`: its `compositionInventory()` owns every preset read, and this package only maps root-fiber states onto the public phase vocabulary. ### The phase mapping @@ -93,7 +97,8 @@ None; this package neither assembles nor sends a provider request. These limits define what a point-in-time inventory cannot tell a client. They are current package constraints, not a task backlog. - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. -- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane. +- **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty. ### Dev Note diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index eab5409c23..6dacf80788 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -1,5 +1,5 @@ --- -description: "当前 Cordis Loader 插件状态的只读投影:面向 web GUI 宿主客户端的 pluginInventory 服务及其 pluginInventory/list Remote。" +description: "当前 Cordis Loader 插件状态的只读投影,并附带每个 Agent 预设的组合:面向 web GUI 宿主客户端的 pluginInventory 服务及其 pluginInventory/list Remote。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -客户端与设置页可以展示宿主当前组合了什么:调用 `pluginInventory/list` 即按 Loader 顺序返回当前的非组条目——条目 id、模块标识、有效启用状态与根 Fiber 阶段(`pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活根 Fiber 时为 `null`)。该快照只表示调用当下:Loader 是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.zh.md) 组合消费这个 Remote,而不导入 Host 实现。 +客户端与设置页可以展示宿主当前组合了什么:调用 `pluginInventory/list` 即按 Loader 顺序返回当前的非组条目——条目 id、模块标识、有效启用状态与根 Fiber 阶段(`pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活根 Fiber 时为 `null`)。当部署组合了 Agent 预设 roster 时,快照还携带每个预设一组——id、显示名、默认标记、健康状态与压平后的组合行——因为挂载 roster 的部署把模型侧插件运行在预设组合里,而不是 Loader 自己的条目上。该快照只表示调用当下:Loader 是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.zh.md) 组合消费这个 Remote,而不导入 Host 实现。 ## 目录 @@ -25,12 +25,16 @@ kind: "package-reference" ## 使用本包 -当客户端或设置页需要展示宿主当前组合了什么——哪些插件已加载、已启用、是否存活——时调用 `pluginInventory/list`。Remote 是唯一入口:该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。 +当客户端或设置页需要展示宿主当前组合了什么——哪些插件已加载、已启用、是否存活,以及每个 Agent 预设会给会话什么——时调用 `pluginInventory/list`。Remote 是唯一入口:该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。 ### 快照包含什么 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。 +### 每个预设的组合 + +组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新 standing 世代作答;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。 + ### 你能用它做什么、不能做什么 该清单是供展示与诊断的快照:客户端可以渲染名单、标出失败条目,并通过比较快照检测变化。它不能启用、停用、添加或移除插件,也不携带历史——已经失败并被移除的 fiber 缺席。由于服务每次调用都读取 Loader,答案总是反映当前组合,而不是缓存视图。 @@ -45,7 +49,7 @@ kind: "package-reference" ### 设计理念 -网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 plugin/status 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。 +网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 plugin/status 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。Agent 预设 roster 是每次调用经 `ctx.get('agentPresets')` 解析的可选伙伴:所有预设读取都由它的 `compositionInventory()` 负责,本包只把根 Fiber 状态映射到公共阶段词汇。 ### 阶段映射 @@ -93,7 +97,8 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产 这些限制说明一个点时刻清单无法告诉客户端什么。它们是当前包约束,不是任务积压。 - **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 -- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 +- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。 +- **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。 ### 开发备注 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index e9db804471..8c96377d6a 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -53,13 +53,20 @@ }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index ff5394c564..3beef6f0c0 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -2,10 +2,13 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' +// Type-only: the optional agent-preset roster resolved through `ctx.get`. +import type {} from '@deepseek-ai/dsh-agent-presets' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' import type { + AgentPresetPluginGroup, PluginEntryId, PluginFiberPhase, PluginInventoryEntry, @@ -51,10 +54,16 @@ export class PluginInventoryGateway extends TypertRemoteService { * Read the Loader directly on every call. Cordis's internal plugin/status * events already maintain Entry.fiber and Fiber.state, so a second cache * would only add another lifecycle truth to keep synchronized. - * @returns Current non-group Loader entries in Loader order. + * + * When an agent-preset roster is composed, the snapshot also carries each + * preset's composition rows, because those rows — not the Loader's own + * entries — are where a deployment that mounts the roster runs its + * model-facing plugins. + * @returns Current non-group Loader entries in Loader order, with per-preset + * compositions when a roster is composed. */ @Remote('list') - list(): PluginInventorySnapshot { + async list(): Promise { const entries: PluginInventoryEntry[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.options.group) continue @@ -65,7 +74,18 @@ export class PluginInventoryGateway extends TypertRemoteService { fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], }) } - return { entries } + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return { entries } + const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map( + composition => ({ + ...composition, + rows: composition.rows.map(({ fiberState, ...row }) => ({ + ...row, + fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState], + })), + }), + ) + return { entries, agentPresets } } } diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index f5678fc3c2..301e194a65 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -22,7 +22,47 @@ export interface PluginInventoryEntry { readonly fiberPhase: PluginFiberPhase } +/** Effective enablement of one preset composition row. */ +export type PresetPluginEnablement = boolean | 'conditional' + +/** One plugin row an agent preset's composition names. */ +export interface AgentPresetPluginRow { + /** Composition row id, or null when the row declares none. */ + readonly entryId: string | null + /** Module specifier the row names. */ + readonly moduleName: string + /** + * Effective enablement, including disabled ancestor groups. `'conditional'` + * marks a `!!js` disabled expression on a composition no session has + * mounted, which only a Loader context can decide. + */ + readonly enabled: PresetPluginEnablement + /** The row's own `!!js` disabled expression, when it carries one. */ + readonly condition?: string + /** Root-fiber phase when the composition is live; null otherwise. */ + readonly fiberPhase: PluginFiberPhase +} + +/** One agent preset's identity and flattened composition in the inventory. */ +export interface AgentPresetPluginGroup { + /** Stable preset id. */ + readonly id: string + /** Display name the preset published; a reader falls back to the id. */ + readonly name?: string + /** Whether a session naming no preset composes this one. */ + readonly isDefault: boolean + /** Why this preset's composition cannot be read; absent when rows answer. */ + readonly broken?: string + /** Plugin rows in composition order; empty when the preset is broken. */ + readonly rows: readonly AgentPresetPluginRow[] +} + /** Point-in-time inventory returned by the plugin inventory Remote. */ export interface PluginInventorySnapshot { readonly entries: readonly PluginInventoryEntry[] + /** + * Per-preset compositions, present only when an agent-preset roster is + * composed in this deployment. + */ + readonly agentPresets?: readonly AgentPresetPluginGroup[] } diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index cd43c492a8..107a0db6ee 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context, type Plugin } from '@deepseek-ai/cordis' +import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' import PluginInventoryGateway from '../src/index.ts' const contexts: Context[] = [] @@ -52,7 +53,9 @@ describe('PluginInventoryGateway', () => { }) await ctx.loader.create({ name: 'cordis:active', group: true }) - const snapshot = inventory.list() + const snapshot = await inventory.list() + // No agent-preset roster is composed, so the snapshot carries no presets. + expect(snapshot.agentPresets).toBeUndefined() expect(snapshot.entries).toHaveLength(3) expect(snapshot.entries).toEqual(expect.arrayContaining([ { @@ -76,7 +79,7 @@ describe('PluginInventoryGateway', () => { ])) await ctx.loader.update(activeId, { disabled: true }) - expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + expect((await inventory.list()).entries.find(entry => entry.entryId === activeId)).toEqual({ entryId: activeId, moduleName: 'cordis:active', enabled: false, @@ -84,6 +87,38 @@ describe('PluginInventoryGateway', () => { }) await ctx.loader.remove(pendingId) - expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) + + it('carries each composed preset with root-fiber states mapped to phases', async () => { + const { ctx, inventory } = await harness() + ctx.provide('agentPresets', { + compositionInventory: async () => [ + { + id: 'standard', + name: '标准模式', + isDefault: true, + rows: [ + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, fiberState: FiberState.ACTIVE }, + { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x' }, + ], + }, + { id: 'damaged', isDefault: false, broken: 'the composition file is missing', rows: [] }, + ], + } as Partial as never) + + const snapshot = await inventory.list() + expect(snapshot.agentPresets).toEqual([ + { + id: 'standard', + name: '标准模式', + isDefault: true, + rows: [ + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, fiberPhase: 'active' }, + { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x', fiberPhase: null }, + ], + }, + { id: 'damaged', isDefault: false, broken: 'the composition file is missing', rows: [] }, + ]) }) }) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 5bd45b3f3c..b56a8291ec 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../util/brand" }, diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 0ff1f1d20f..401ec1eefe 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 5b83ed85a6691dabb6ec340b6462f17d5281efdd -README.zh.md: f55f1e383d11b28fa1d8ff52aec1e94a351914f7 +README.md: bc157f664ecc2aabb61baaa4527f0134133aa344 +README.zh.md: 7680fcf76653f299d068290d74779e21969cf6a6 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5b83ed85a6..bc157f664e 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -105,6 +105,7 @@ This section explains the design behind the roster and the standing mount; obser |---|---| | [`src/index.ts`](src/index.ts) | Service entry: `Config` schema, settings namespace, roster API, standing-mount coordination | | [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, health checks, id validation, ordering | +| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates, mount reads with fiber states | | [`src/preset.ts`](src/preset.ts) | Vocabulary: preset id rule, `AgentPreset` and `PresetRoot`, error types | | [`src/mount.ts`](src/mount.ts) | Subtree mounting, host base-URL handling, mount audit, `write()` suppression | | [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, permission tightening | @@ -117,6 +118,10 @@ This section explains the design behind the roster and the standing mount; obser `ensureStanding` keeps one pending promise per preset id, single-flight, so two agents racing the first use of a preset share one composition. A settled failure is removed so a later session retries a preset whose file has been fixed. The mount runs in the roster service's own untraced context — a subtree minted from a traced context would resolve services through the caller's shadow fiber — so it survives every agent and unwinds only with whole-tree teardown. `serviceForAgent` reads an agent's instance of a service its preset mounted behind an `isolate` realm, which is otherwise invisible outside the group. +### The composition inventory + +`compositionInventory()` answers plugin-listing surfaces with each preset's flattened rows beside its roster identity: a preset with a live standing mount answers from its newest generation's Loader entries, and one never composed since boot answers from its composition file with `!!js` disabled gates evaluated against the Loader context, so both answers reflect the same host. Reading never mounts a preset — a settings page listing every composition activates none of them. A gate the evaluator refuses stays `'conditional'`, and a file that stopped reading as a composition between discovery's health verdict and the row read is reported broken with the raced reason rather than dropped. + ### The mount audit A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index f55f1e383d..7680fcf766 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -105,6 +105,7 @@ agent-presets: |---|---| | [`src/index.ts`](src/index.ts) | 服务入口:`Config` schema、settings 命名空间、名单 API、常驻挂载协调 | | [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、健康检查、id 校验、排序 | +| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门)与挂载读取(携带 fiber 状态) | | [`src/preset.ts`](src/preset.ts) | 词汇体系:preset id 规则、`AgentPreset` 与 `PresetRoot`、错误类型 | | [`src/mount.ts`](src/mount.ts) | 子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 | | [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 | @@ -117,6 +118,10 @@ agent-presets: `ensureStanding` 为每个 preset id 保留一个进行中的 promise(single-flight),因此两个竞争首次使用同一 preset 的 agent 共享一份组装。已结算的失败会被移除,以便后续会话重试文件已被修复的 preset。挂载运行在 roster 服务自己的未追踪上下文中——从被追踪上下文派生的子树会经调用方的 shadow fiber 解析服务——因此它比任何 agent 都活得久,只随整棵树卸载。`serviceForAgent` 读取某 agent 对其 preset 挂在 `isolate` realm 之后(组外不可见)的某个服务实例。 +### 组合清单 + +`compositionInventory()` 向插件清单表面提供每个预设的压平行及其名单身份:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答,开机以来从未被组合的预设由其组合文件作答,`!!js` disabled 门用 Loader 上下文求值,使两种答案反映同一台宿主。读取从不挂载预设——列出所有组合的设置页不会激活其中任何一个。求值器拒绝的门保持 `'conditional'`;在发现的健康裁决与行读取之间变得不可读的文件,会携带竞态原因报告为 broken,而不是被静默丢弃。 + ### 挂载审计 直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 diff --git a/packages/preset/agent-presets/src/composition-inventory.ts b/packages/preset/agent-presets/src/composition-inventory.ts new file mode 100644 index 0000000000..29ac401eaa --- /dev/null +++ b/packages/preset/agent-presets/src/composition-inventory.ts @@ -0,0 +1,195 @@ +/** + * Structured composition reads for plugin-listing surfaces: the plugin rows + * each preset names, with each row's effective enablement. A preset with a + * live standing mount answers from that mount's Loader entries — evaluated + * `disabled`, real root-fiber states; a preset no session has composed since + * boot answers from its composition file, with `!!js` disabled expressions + * evaluated through the caller-supplied Loader evaluator so the file answer + * matches the decision a mount on this host would make. A row whose + * expression the evaluator refuses stays `'conditional'`. + * @module @deepseek-ai/dsh-agent-presets/composition-inventory + */ + +import { readFile } from 'node:fs/promises' +import { load } from 'js-yaml' +import type { FiberState } from '@deepseek-ai/cordis' +import { isJsExpr, type EntryTree } from '@deepseek-ai/cordis-plugin-loader' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { entryListProblem } from './discovery.ts' + +/** + * Effective enablement of one composition row: a literal or evaluated + * boolean, or `'conditional'` when a `!!js` disabled expression could not be + * evaluated outside a mount. + */ +export type CompositionRowEnablement = boolean | 'conditional' + +/** + * Evaluate one `!!js` disabled expression the way the Loader would at a mount + * decision. Throwing refuses the answer: the row is reported `'conditional'` + * rather than guessed. + */ +export type DisabledExpressionEvaluator = (expression: string) => unknown + +/** One plugin row a preset composition names. */ +export interface AgentPresetCompositionRow { + /** + * The Loader-tree entry id when read from a live mount, else the id the + * composition file declares; null when the file row declares none. + */ + readonly entryId: string | null + /** Module specifier the row names. */ + readonly moduleName: string + /** Effective enablement, including disabled ancestor groups. */ + readonly enabled: CompositionRowEnablement + /** The row's own `!!js` disabled expression, when it carries one. */ + readonly condition?: string + /** Root-fiber state, present only when read from a live mount. */ + readonly fiberState?: FiberState +} + +/** One preset's roster identity beside its composition rows. */ +export interface AgentPresetComposition { + /** Stable preset id. */ + readonly id: string + /** Display name the preset published. */ + readonly name?: string + /** Whether a session naming no preset composes this one. */ + readonly isDefault: boolean + /** Why this preset's rows cannot be read; absent when {@link rows} answers. */ + readonly broken?: string + /** Composition rows in composition order; empty when the preset is broken. */ + readonly rows: readonly AgentPresetCompositionRow[] +} + +/** + * One `disabled` node's contribution to effective enablement, mirroring the + * Loader's own reading: a `!!js` expression is asked of the evaluator — a + * refusal (throw) leaves the decision to a mount — and anything else disables + * exactly when `Boolean(value)` does. + * @param value - the raw `disabled` node of one composition row. + * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @returns true (disabled), false (enabled), or `'conditional'`. + */ +function disabledContribution( + value: unknown, + evaluateExpression: DisabledExpressionEvaluator, +): boolean | 'conditional' { + if (isJsExpr(value)) { + try { + return Boolean(evaluateExpression(value.__jsExpr)) + } catch { + // The evaluator refused (a malformed or context-dependent expression); + // only a real mount decision can answer, so the row stays conditional. + return 'conditional' + } + } + return Boolean(value) +} + +/** + * Combine an ancestor group's disabled state with a row's own, the way the + * Loader walks owning groups: any literal true disables, otherwise any + * expression leaves the decision to a mount. + * @param outer - the combined ancestor contribution. + * @param own - this row's contribution. + * @returns the row's effective disabled state. + */ +function combineDisabled( + outer: boolean | 'conditional', + own: boolean | 'conditional', +): boolean | 'conditional' { + if (outer === true || own === true) return true + if (outer === 'conditional' || own === 'conditional') return 'conditional' + return false +} + +/** A parsed composition row after {@link entryListProblem} accepted the list. */ +interface RawRow { + readonly id?: unknown + readonly name: string + readonly group?: unknown + readonly config?: unknown + readonly disabled?: unknown +} + +/** + * Flatten one parsed row list into plugin rows. Group rows are structural — + * the Loader reports a group entry as always enabled and lets children + * inherit its `disabled` — so only their children are emitted. + * @param rows - the parsed rows, shape-checked by the caller. + * @param outerDisabled - the combined ancestor-group disabled state. + * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @param found - the accumulator receiving flattened rows. + */ +function flattenRows( + rows: readonly unknown[], + outerDisabled: boolean | 'conditional', + evaluateExpression: DisabledExpressionEvaluator, + found: AgentPresetCompositionRow[], +): void { + for (const value of rows) { + const row = value as RawRow + const disabled = combineDisabled(outerDisabled, disabledContribution(row.disabled, evaluateExpression)) + if (row.group === true) { + flattenRows(row.config as readonly unknown[], disabled, evaluateExpression, found) + continue + } + found.push({ + entryId: typeof row.id === 'string' && row.id !== '' ? row.id : null, + moduleName: row.name, + enabled: disabled === true ? false : disabled === 'conditional' ? 'conditional' : true, + ...isJsExpr(row.disabled) ? { condition: row.disabled.__jsExpr } : {}, + }) + } +} + +/** + * Plugin rows of one composition file, for a preset with no live mount. + * + * Parsed with the Loader's own dialect ({@link entryListSchema}), so the rows + * reported are the rows a mount would start from. A file that stopped reading + * as a composition — discovery judged the preset healthy moments earlier, so + * only an edit racing this read gets here — answers as broken with the raced + * reason rather than dropping the rows silently. + * @param path - absolute path of the composition file. + * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @returns flattened rows in composition order, or why they cannot be read. + */ +export async function fileComposition( + path: string, + evaluateExpression: DisabledExpressionEvaluator, +): Promise<{ rows: AgentPresetCompositionRow[] } | { broken: string }> { + let rows: unknown + try { + rows = load(await readFile(path, 'utf8'), { schema: entryListSchema }) + } catch (error) { + /* v8 ignore next -- fs and js-yaml throw Errors for every failure here; the fallback keeps a hostile value readable */ + return { broken: error instanceof Error ? error.message : String(error) } + } + const problem = entryListProblem(rows) + if (problem !== undefined) return { broken: problem } + const found: AgentPresetCompositionRow[] = [] + flattenRows(rows as readonly unknown[], false, evaluateExpression, found) + return { rows: found } +} + +/** + * Plugin rows of one live standing composition, in Loader-entry order. + * @param tree - the standing mount's entry tree. + * @returns rows with the Loader's evaluated enablement and root-fiber states. + */ +export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionRow[] { + const found: AgentPresetCompositionRow[] = [] + for (const entry of tree.entries()) { + if (entry.options.group) continue + found.push({ + entryId: entry.id, + moduleName: entry.options.name, + enabled: !entry.disabled, + ...isJsExpr(entry.options.disabled) ? { condition: entry.options.disabled.__jsExpr } : {}, + ...entry.fiber === undefined ? {} : { fiberState: entry.fiber.state }, + }) + } + return found +} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 915ee90f45..8d39410b00 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -67,11 +67,14 @@ export const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../presets/', import.m * that produces a file the loader cannot even begin with — and it must accept * everything the loader accepts, which is why rows are only required to be * maps carrying a plugin `name` (groups recurse into their own lists). + * + * Shared with the composition inventory, whose file reads race edits against + * the health verdict and must judge the raced content by the same rule. * @param rows - the parsed composition document. * @param at - row-path prefix for nested diagnostics, empty at the top level. * @returns one human-readable reason, or undefined when the shape holds. */ -function entryListProblem(rows: unknown, at = ''): string | undefined { +export function entryListProblem(rows: unknown, at = ''): string | undefined { if (!Array.isArray(rows)) { return at === '' ? 'the composition must be a top-level list of plugin rows' diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 7dbcefb3d3..3033cf47b9 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -23,6 +23,7 @@ import { stat } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' import z from '@deepseek-ai/schemastery' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' @@ -40,13 +41,20 @@ import { copyComposition, deleteComposition, readComposition, InvalidPresetIdError, PresetExistsError, PresetNotWritableError, } from './authoring.ts' -import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' +import { livePresetMounts, mountPreset, serviceForAgent, standingMountFor } from './mount.ts' +import { + fileComposition, mountedCompositionRows, + type AgentPresetComposition, +} from './composition-inventory.ts' import { PresetLockedError, PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot, } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' export type * from './types.ts' +export type { + AgentPresetComposition, AgentPresetCompositionRow, CompositionRowEnablement, +} from './composition-inventory.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' @@ -330,6 +338,51 @@ export class AgentPresets extends TypertRemoteService { } } + /** + * Every preset's composition as flattened plugin rows, for plugin-listing + * surfaces beside the roster's own picker. + * + * A preset with a live standing mount answers from its newest generation's + * Loader entries — the composition new sessions join — and one never + * composed since boot answers from its file, with `!!js` disabled gates + * evaluated against the Loader context so both answers reflect the same + * host. Reading never mounts: an unmounted preset is parsed, not composed, + * so listing a preset's plugins cannot activate them early. A composition + * that stopped reading between discovery's health verdict and this read is + * reported broken with the raced reason rather than dropped. + * @returns one composition per roster preset, in roster order. + */ + async compositionInventory(): Promise { + const defaultId = this.defaultId + // The Loader's own expression scope: what a mount decision would consult + // (entry.ctx only adds the entry itself, which no disabled gate reads). + const evaluateExpression = (expression: string): unknown => evaluate(this.ctx.loader.ctx, expression) + const found: AgentPresetComposition[] = [] + for (const preset of await this.list()) { + const identity = { + id: preset.id, + ...preset.name === undefined ? {} : { name: preset.name }, + isDefault: preset.id === defaultId, + } + if (preset.broken !== undefined) { + found.push({ ...identity, broken: preset.broken, rows: [] }) + continue + } + // Newest generation last: mount records keep insertion order, and a + // superseded generation's record precedes its replacement's. + const mount = livePresetMounts().findLast(candidate => candidate.presetId === preset.id) + if (mount !== undefined) { + found.push({ ...identity, rows: mountedCompositionRows(mount.tree) }) + continue + } + const read = await fileComposition(preset.path, evaluateExpression) + found.push('broken' in read + ? { ...identity, broken: read.broken, rows: [] } + : { ...identity, rows: read.rows }) + } + return found + } + /** * Resolve one preset by id. * diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts new file mode 100644 index 0000000000..8240ce557f --- /dev/null +++ b/packages/preset/agent-presets/tests/composition-inventory.spec.ts @@ -0,0 +1,309 @@ +/** + * Structured composition reads: the flattened plugin rows a preset names, + * answered from the composition file while no session has mounted the preset + * and from the standing mount once one has, with a composition that cannot be + * read reported broken by reason instead of dropped. + */ + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context, FiberState } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' +import type { Config } from '@deepseek-ai/dsh-agent-presets' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { fileComposition, mountedCompositionRows } from '../src/composition-inventory.ts' +import { livePresetMounts } from '../src/mount.ts' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = { path: join(FIXTURES, 'system'), trust: 'system' as const } +// A row naming a package installed beside the harness, the way authored rows do. +const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' + +const contexts: Context[] = [] + +/** A Loader-context evaluator over an empty scope, enough for literal gates. */ +const evaluateExpression = (expression: string): unknown => evaluate({}, expression) +/** An evaluator that refuses every expression, leaving rows conditional. */ +const refuseExpression = (): never => { throw new Error('no loader context') } + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function harness(roster: Config): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, roster) + return ctx +} + +describe('fileComposition', () => { + it('flattens groups and keeps refused expressions conditional', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + const path = join(dir, COMPOSITION_FILE) + await writeFile(path, [ + '- id: alpha', + ' name: pkg-alpha', + '- name: pkg-anonymous', + '- id: off', + ' name: pkg-off', + ' disabled: true', + '- id: cond', + ' name: pkg-cond', + ' disabled: !!js process.platform === \'win32\'', + '- id: grp', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: child', + ' name: pkg-child', + ' - id: child-off', + ' name: pkg-child-off', + ' disabled: true', + '- id: grp-off', + ' name: cordis:group', + ' group: true', + ' disabled: true', + ' config:', + ' - id: buried', + ' name: pkg-buried', + '- id: grp-cond', + ' name: cordis:group', + ' group: true', + ' disabled: !!js 1', + ' config:', + ' - id: maybe', + ' name: pkg-maybe', + ' - id: certainly-off', + ' name: pkg-certainly-off', + ' disabled: true', + ].join('\n')) + + expect(await fileComposition(path, refuseExpression)).toEqual({ + rows: [ + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true }, + { entryId: null, moduleName: 'pkg-anonymous', enabled: true }, + { entryId: 'off', moduleName: 'pkg-off', enabled: false }, + { + entryId: 'cond', + moduleName: 'pkg-cond', + enabled: 'conditional', + condition: 'process.platform === \'win32\'', + }, + { entryId: 'child', moduleName: 'pkg-child', enabled: true }, + { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false }, + { entryId: 'buried', moduleName: 'pkg-buried', enabled: false }, + { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional' }, + { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false }, + ], + }) + }) + + it('evaluates decidable gates the way a mount would', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + const path = join(dir, COMPOSITION_FILE) + await writeFile(path, [ + '- id: off', + ' name: pkg-off', + ' disabled: !!js 1 === 1', + '- id: on', + ' name: pkg-on', + ' disabled: !!js 1 === 2', + ].join('\n')) + + expect(await fileComposition(path, evaluateExpression)).toEqual({ + rows: [ + { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1' }, + { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2' }, + ], + }) + }) + + it('answers broken for a file that stopped reading as a composition', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + + const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression) + expect(missing).toHaveProperty('broken') + + const unparsable = join(dir, 'unparsable.yml') + await writeFile(unparsable, 'foo: [') + const yaml = await fileComposition(unparsable, refuseExpression) + expect('broken' in yaml && yaml.broken.length > 0).toBe(true) + + const rowless = join(dir, 'rowless.yml') + await writeFile(rowless, 'foo: bar\n') + expect(await fileComposition(rowless, refuseExpression)).toEqual({ + broken: 'the composition must be a top-level list of plugin rows', + }) + }) +}) + +describe('mountedCompositionRows', () => { + it('reads evaluated enablement and root-fiber states, skipping group rows', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = () => {} + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const disabledId = await ctx.loader.create({ name: 'cordis:active', disabled: true }) + const evaluatedId = await ctx.loader.create({ + name: 'cordis:active', + // The YAML `!!js` tag deserializes to exactly this object; EntryOptions + // types the field by its literal form only. + disabled: { __jsExpr: 'false' } as unknown as boolean, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(mountedCompositionRows(ctx.loader)).toEqual([ + { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE }, + { entryId: disabledId, moduleName: 'cordis:active', enabled: false }, + { + entryId: evaluatedId, + moduleName: 'cordis:active', + enabled: true, + condition: 'false', + fiberState: FiberState.ACTIVE, + }, + ]) + }) +}) + +describe('AgentPresets.compositionInventory', () => { + it('reads unmounted presets from their files, marking the default and metadata', async () => { + const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + await mkdir(join(userRoot, 'documented')) + await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [ + VALID.trimEnd(), + '- id: gated', + ' name: \'@deepseek-ai/dsh-system-prompt\'', + ' disabled: !!js 1 === 1', + '- id: undecidable', + ' name: \'@deepseek-ai/dsh-system-prompt\'', + ' disabled: !!js nothing.here', + ].join('\n')) + await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\n') + const ctx = await harness({ + default: 'minimal', + roots: [SYSTEM_ROOT, { path: userRoot, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + }) + + expect(await ctx.agentPresets.compositionInventory()).toEqual([ + { + id: 'minimal', + isDefault: true, + rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true }], + }, + { + id: 'standard', + isDefault: false, + rows: [ + { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, + ], + }, + { + id: 'documented', + name: '我的模式', + isDefault: false, + rows: [ + { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true }, + // The platform-gate shape: the service evaluates it with the + // Loader's own scope, so the file answer matches a mount's. + { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1' }, + // An expression the evaluator refuses stays a mount's decision. + { + entryId: 'undecidable', + moduleName: '@deepseek-ai/dsh-system-prompt', + enabled: 'conditional', + condition: 'nothing.here', + }, + ], + }, + ]) + // Reading is never mounting: every unmounted preset above was answered + // from its file, so listing plugins cannot activate a preset early. + expect(livePresetMounts()).toEqual([]) + }) + + it('reads a mounted preset from its standing composition', async () => { + const ctx = await harness({ + default: 'standard', + roots: [SYSTEM_ROOT], + includeShippedRoot: false, + includeUserRoot: false, + }) + await ctx.agents.create({ + sessionId: SessionId('composition-inventory'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + const standard = (await ctx.agentPresets.compositionInventory()) + .find(composition => composition.id === 'standard') + expect(standard?.rows).toEqual([ + { + entryId: 'alpha', + moduleName: '../../plugins/contribute.js', + enabled: true, + fiberState: FiberState.ACTIVE, + }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, + ]) + }) + + it('keeps a broken preset on the inventory with its discovery reason', async () => { + const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + await mkdir(join(userRoot, 'damaged')) + const ctx = await harness({ + default: 'minimal', + roots: [SYSTEM_ROOT, { path: userRoot, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + }) + + const damaged = (await ctx.agentPresets.compositionInventory()) + .find(composition => composition.id === 'damaged') + expect(damaged?.rows).toEqual([]) + expect(damaged?.broken).toContain('is missing') + }) + + it('reports a composition that raced discovery as broken instead of dropping it', async () => { + const ctx = await harness({ + default: 'minimal', + roots: [SYSTEM_ROOT], + includeShippedRoot: false, + includeUserRoot: false, + }) + // Discovery judged the preset healthy, then the file vanished before the + // row read: the inventory keeps the preset and carries the raced reason. + vi.spyOn(ctx.agentPresets, 'list').mockResolvedValue([ + { id: 'ghost', trust: 'user', path: join(FIXTURES, 'ghost', COMPOSITION_FILE) }, + ]) + + const [ghost] = await ctx.agentPresets.compositionInventory() + expect(ghost?.rows).toEqual([]) + expect(ghost?.broken).toBeDefined() + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 926ed9ce51..c357b1f257 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -18,6 +18,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/loader" + }, { "path": "../../core/agent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d5d75a10..eebbf4be72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5919,6 +5919,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1bae81253b..266438678a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -656,6 +656,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md', AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md', + AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md', PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md', BashEnvContributor: 'service-local extension type is owned by packages/shell/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts', From 5eb7195f9ddeb338b4c8f04e5cea4255872826af Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 17:13:45 +0800 Subject: [PATCH 086/429] refactor(plugin-inventory): menu-pill preset switcher, collapsible groups, inline preset-provided rows Review-driven refinements to the scope-grouped plugin list: - The preset switcher becomes the General-settings selector pill over the shared Menu primitive instead of a native select, and the preset group is collapsible like the global one (search still forces both open). - The session-plugins drawer is removed: rows the presets took over sit inline in the global list with the preset-provided tag and per-preset details, since the preset group above already shows those compositions. - The status dot renders only for a live root fiber, so file-state rows of an unmounted preset carry their enablement tag alone instead of a column of grey dots. - PresetTree reclaims the owning entry's subtree slot: EntryTree's constructor filed the standing mount under the roster's own Loader row, so after the first session composed a preset the whole composition leaked into root loader.entries() as host rows (each preset overwriting the last). A regression test holds the root entry list identical across a mount. --- ...in-inventory-agent-preset-scopes.i18n.yaml | 4 +- ...29-plugin-inventory-agent-preset-scopes.md | 6 +- ...plugin-inventory-agent-preset-scopes.zh.md | 6 +- apps/web/tests/settings-chrome.e2e.ts | 9 +- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 8 +- .../ui-settings-plugin-inventory/README.zh.md | 8 +- .../PluginInventorySettingsTab.module.css | 64 ++++--- .../src/client/PluginInventorySettingsTab.tsx | 160 +++++++++--------- .../src/client/locales.ts | 8 +- .../tests/components.client.spec.tsx | 77 ++++++--- packages/preset/agent-presets/src/mount.ts | 8 + .../tests/composition-inventory.spec.ts | 32 ++++ 13 files changed, 239 insertions(+), 155 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml index bb1705fca7..dd06ae7c96 100644 --- a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.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-29-plugin-inventory-agent-preset-scopes.md -2026-08-29-plugin-inventory-agent-preset-scopes.md: ec738ee0ff325e14677407da0ce4af8ffe3fe718 -2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: 7804203350b4173a6dfd058447aee95e918cda74 +2026-08-29-plugin-inventory-agent-preset-scopes.md: 8615e057bec7b269fe58c18678d8cc96479d451f +2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: d71ddf2dc082193c5c7530ba34680102ce8c3d2e diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md index ec738ee0ff..8615e057be 100644 --- a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md @@ -12,15 +12,15 @@ English | [中文](2026-08-29-plugin-inventory-agent-preset-scopes.zh.md) **The inventory speaks for both planes.** `pluginInventory/list` gains an optional `agentPresets` block — one group per roster preset with id, display name, default marking, health, and flattened composition rows — supplied by the new `AgentPresets.compositionInventory()`: a preset with a live standing mount answers from its newest generation's Loader entries, and one never composed since boot answers from its composition file. `dsh-host-plugin-inventory` resolves the roster as an optional peer through `ctx.get('agentPresets')` (the `plugin-package-inventory-deepseek` pattern) and only maps root-fiber states onto its public phase vocabulary, so deployments without a roster keep serving Loader entries alone with the field absent. -**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read. +**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read. Building this surface also exposed the reverse leak: `EntryTree`'s constructor files every new tree under the nearest owning Loader entry's `subtree` slot, so the first standing mount hung the whole preset composition off the roster's own row and root `loader.entries()` walked it as host entries. `PresetTree` now reclaims the slot, restoring the standing mount's documented absence from the Loader, and a regression test holds the root entry list identical across a mount. -**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first behind a display-only switcher that opens on the default preset and writes no settings — inspecting `minimal` must not change what new sessions run. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled folds into a "session plugins" drawer that names its providers — a third state instead of the generic "disabled" that started this. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces the disclosures open, and points at matches sitting in unselected presets. +**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first, collapsible and open by default, behind a display-only switcher — the General-settings selector pill over a menu — that opens on the default preset and writes no settings, because inspecting `minimal` must not change what new sessions run. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled is marked preset-provided in place, its details naming the enabling presets — a third state instead of the generic "disabled" that started this, and deliberately not a sub-group: the preset group above already shows those plugins as compositions, so a second cluster restating them earned its removal. The status dot appears only for a live root fiber — a file-state row carries its enablement tag alone, so an unmounted preset does not read as a column of grey mystery dots. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces them open, and points at matches sitting in unselected presets. **The General row is deleted, not relocated.** The default keeps two surfaces that can still act on it — the roster section's make-default beside the visible roster, and the new-session chip for the session about to start — so `ui-agent-preset` drops the row, its menu, and the write/writability half of its settings store, which slims to the display roster the header label reads. ## Alternatives considered -**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the drawer's provider list and the search pointers preserve the cross-scope answer the all-at-once layout was buying. +**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the per-row provider details and the search pointers preserve the cross-scope answer the all-at-once layout was buying. **Keep file-state gates unevaluated (`conditional` until first mount).** Honest but it re-created the misleading reading this change removes: on a cold host the default preset's `tool-bash` read as "conditional" and its host row fell back to plain "disabled" until the first session mounted the preset. diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md index 7804203350..d71ddf2dc0 100644 --- a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md @@ -12,15 +12,15 @@ **清单同时陈述两个平面。**`pluginInventory/list` 增加可选的 `agentPresets` 块——每个名单预设一组,含 id、显示名、默认标记、健康状态与压平的组合行——由新增的 `AgentPresets.compositionInventory()` 提供:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答,开机以来从未被组合的预设由其组合文件作答。`dsh-host-plugin-inventory` 经 `ctx.get('agentPresets')` 把名单当作可选伙伴解析(即 `plugin-package-inventory-deepseek` 的模式),自己只把根 Fiber 状态映射到公共阶段词汇,因此没有名单的部署继续只提供 Loader 条目、字段缺席。 -**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。 +**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。搭这个表面还暴露了反向泄漏:`EntryTree` 的构造器把每棵新树挂到最近拥有者 Loader 条目的 `subtree` 槽上,于是第一个 standing mount 把整棵预设组合挂在了 roster 自己的行下,根 `loader.entries()` 把它当宿主条目走了一遍。`PresetTree` 现在归还该槽位,恢复 standing mount「不在 Loader 里」的书面契约;回归测试钉住挂载前后根条目列表逐项相同。 -**列表按作用域分组,误导行获得自己的状态。**预设组在前,其切换器只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,收进"会话插件"抽屉并列出提供它的预设——用第三种状态取代引发这一切的笼统"已停用"。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开折叠,并指出未选中预设里的匹配。 +**列表按作用域分组,误导行获得自己的状态。**预设组在前、可折叠且默认展开,其切换器是通用设置同款的「选择胶囊 + 菜单」控件,只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,就地标记为预设提供并在详情里列出启用它的预设——用第三种状态取代引发这一切的笼统"已停用",并且刻意不做成子分组:上方的预设组已经把这些插件按组合展示,一个复述它们的第二个聚簇理应被移除。状态圆点只为存活的根 fiber 渲染——文件态的行只带启停标签,未挂载的预设不会读作一列灰色的谜之圆点。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开分组,并指出未选中预设里的匹配。 **通用设置行是删除,不是搬家。**默认值保留两个仍能作用于它的表面——名单分区的设为默认(名单可见)与新会话 chip(针对即将开始的会话)——因此 `ui-agent-preset` 删掉该行、它的菜单以及 settings store 的写入/可写性半边,后者收敛为标题标签读取的展示名单 store。 ## 考虑过的替代方案 -**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,抽屉的提供者列表与搜索指引保留了全展开布局想买到的跨作用域答案。 +**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,行级的提供者详情与搜索指引保留了全展开布局想买到的跨作用域答案。 **文件态门保持不求值(首次挂载前一律 `conditional`)。**诚实,但重演了本次要消除的误导:冷启动的宿主上,默认预设的 `tool-bash` 读作"条件启用",其全局行在第一个会话挂载预设之前退回单纯的"已停用"。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 4a8fe18989..648bb9c260 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -103,19 +103,18 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() // The preset group opens first with its display-only switcher; the global - // plane starts collapsed and expands on demand, session plugins deeper still. - const presetSwitcher = dialog.getByRole('combobox', { name: '选择要查看的 Agent 预设' }) + // plane starts collapsed and expands on demand. + const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' }) await presetSwitcher.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: /^全局/ }).click() const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) await pluginRow.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: /^会话插件/ }).click() const expectedPluginCount = [...scaffold.ctx.loader.entries()] .filter(entry => !entry.options.group) .length expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) - // Every Loader entry appears exactly once in the global group — the - // session-plugin drawer included, preset compositions excluded. + // Every Loader entry appears exactly once in the global group — rows the + // presets took over included, preset compositions excluded. expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count()) .toBe(expectedPluginCount) expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index f1a56b6c18..0047cf9e07 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/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-plugin-inventory/README.md -README.md: bc89b60e89e0fe257c6f422a2749bd4188fe796c -README.zh.md: 13c09aaeb108120ba22f05d9828da75088a7deb8 +README.md: 718139cc135b8213ea2f3d1aec384f550b57a641 +README.zh.md: 7ebb7d75e6ebd285010a72f175c02ab7c2e802fd diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index bc89b60e89..718139cc13 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders the inventory in two groups. The agent-preset group comes first: a display-only switcher over the roster opens on the default preset, and each composition row is a compact disclosure card carrying its enablement — including `conditional` for a disabled gate only a mount can decide — with provenance facts behind the disclosure. The global group follows collapsed, its header carrying the entry count and a failure count; expanded, failures float first, and entries disabled globally but enabled by presets fold into a session-plugins drawer naming their providers instead of reading as plainly disabled. Search filters both groups, forces the collapsed disclosures open, and points at matches sitting in unselected presets. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details; without a roster the tab renders the global plane alone, expanded. +`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders the inventory in two collapsible groups. The agent-preset group comes first, open by default: a display-only switcher pill over the roster opens on the default preset, and each composition row is a compact disclosure card carrying its enablement — including `conditional` for a disabled gate the Host could not evaluate — with provenance facts behind the disclosure. The global group follows collapsed, its header carrying the entry count and a failure count; expanded, failures float first, and an entry disabled globally but enabled by at least one preset is marked as preset-provided in place — its details name the enabling presets — instead of reading as plainly disabled. Search filters both groups, forces the collapsed groups open, and points at matches sitting in unselected presets. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details; without a roster the tab renders the global plane alone, expanded. ## Table of Contents @@ -29,11 +29,11 @@ Open the Plugins section in Settings and select the **Plugin list** tab to inspe ### Reading a card -Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its enable condition when it carries one; a session-plugins drawer row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Search filters both groups by module name and entry id. +Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its enable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Search filters both groups by module name and entry id. ### The preset switcher -The switcher lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen. +The switcher is the same selector-pill-plus-menu control the General settings rows use. It lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen. ### Retrying a failed read @@ -55,7 +55,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -Row keys are scope-qualified (`global:`, `drawer:`, `preset::`), so one module appearing in several scopes keeps distinct disclosure state; an entry id is shown as detail only when the row declares one and is never classified by string shape. The session-plugins drawer is derived client-side: a global entry joins it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. +Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; an entry id is shown as detail only when the row declares one and is never classified by string shape. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 13c09aaeb1..7ebb7d75e6 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并把清单分成两组渲染。Agent 预设组在前:一个只改显示的切换器覆盖 roster、初始停在默认预设,每个组合行是一张紧凑折叠卡片,携带其启停状态——含只有挂载才能裁决的 disabled 门对应的 `conditional`——出处事实收在折叠里。全局组随后且默认收起,组头带条目计数与失败计数;展开后失败行浮在最前,全局停用但被预设启用的条目收进「会话插件」抽屉并列出提供它的预设,而不是读作单纯的已停用。搜索同时过滤两组、强制撑开收起的折叠,并指出未选中预设里的匹配。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节;没有 roster 时标签页只渲染全局平面并保持展开。 +`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并把清单分成两个可折叠分组渲染。Agent 预设组在前、默认展开:一个只改显示的切换器胶囊覆盖 roster、初始停在默认预设,每个组合行是一张紧凑折叠卡片,携带其启停状态——含宿主无法求值的 disabled 门对应的 `conditional`——出处事实收在折叠里。全局组随后且默认收起,组头带条目计数与失败计数;展开后失败行浮在最前,全局停用但被至少一个预设启用的条目就地标记为预设提供——详情列出启用它的预设——而不是读作单纯的已停用。搜索同时过滤两组、强制撑开收起的分组,并指出未选中预设里的匹配。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节;没有 roster 时标签页只渲染全局平面并保持展开。 ## 目录 @@ -29,11 +29,11 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的启用条件;「会话插件」抽屉行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。搜索按模块名称与条目 id 过滤两组。 +每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的启用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。搜索按模块名称与条目 id 过滤两组。 ### 预设切换器 -切换器列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选默认预设或某个会话的预设仍在原处:Agent 预设分区与新会话页。 +切换器与通用设置各行使用同一种「选择胶囊 + 菜单」控件。它列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选默认预设或某个会话的预设仍在原处:Agent 预设分区与新会话页。 ### 重试失败的读取 @@ -55,7 +55,7 @@ kind: "package-reference" ### 渲染 -行 key 按作用域限定(`global:`、`drawer:`、`preset::`),因此同一模块出现在多个作用域时保持各自的展开状态;条目 id 只在行声明了它时作为详情展示,代码不按字符串形状对它分类。「会话插件」抽屉在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才归入抽屉,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 +行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;条目 id 只在行声明了它时作为详情展示,代码不按字符串形状对它分类。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 41c2d7ef59..60673d222f 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -292,36 +292,59 @@ line-height: 18px; } +/* The preset switcher trigger mirrors the General-settings selector pill. */ .switcher { - max-width: 60%; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; - padding: 3px 8px; - background: var(--dsw-alias-bg-layer-1); + display: inline-flex; + flex: none; + align-items: center; + gap: 10px; + height: 30px; + white-space: nowrap; + border: none; + border-radius: 15px; + padding: 0 12px; + background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; + line-height: 20px; font-weight: 600; + cursor: pointer; } -.badge { +.switcher:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.switcher:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.switcher > .chevron { flex: none; - border-radius: 999px; - padding: 1px 8px; - font-size: 11px; - line-height: 16px; } -.badge[data-kind='default'] { - background: var(--dsw-alias-label-primary); - color: var(--dsw-alias-bg-layer-3); +.switcherLabel { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; } -.badge[data-kind='broken'] { - background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); - color: var(--dsw-alias-state-error-primary); +.iconToggle { + flex: none; + width: auto; + min-height: 0; + padding: 4px; } +.groupBody { + display: flex; + flex-direction: column; + gap: 10px; +} + + .brokenNote { margin: 0; border-radius: 8px; @@ -355,15 +378,6 @@ cursor: pointer; } -.drawer { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 10px; - border: 1px dashed var(--dsw-alias-border-l1); - border-radius: 10px; - padding: 8px 10px 10px; -} .enabledIn { display: flex; diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 7726a9b1ab..e56cbfdef8 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -3,6 +3,7 @@ import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/clien import { IconChevronDownOutline14, IconSearchOutline16, + Menu, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginInventoryLocaleKey } from './locales.ts' @@ -141,13 +142,13 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: { ) } -/** Status dot naming the root-fiber phase. */ -function PhaseDot({ phase, t }: { readonly phase: PluginFiberPhase; readonly t: Translate }): ReactNode { +/** Status dot naming a live root-fiber phase; rows with no live fiber show none. */ +function PhaseDot({ phase, t }: { readonly phase: NonNullable; readonly t: Translate }): ReactNode { const status = phaseLabel(phase, t) return ( (null) const [chosenPreset, setChosenPreset] = useState(null) + const [switcherOpen, setSwitcherOpen] = useState(false) + const [presetOpen, setPresetOpen] = useState(null) const [globalOpen, setGlobalOpen] = useState(null) - const [drawerOpen, setDrawerOpen] = useState(false) const [state, setState] = useState({ status: 'loading' }) useEffect(() => { @@ -202,21 +204,17 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT const entries = snapshot?.entries ?? [] const failedEntries: PluginInventoryEntry[] = [] - const drawerEntries: { entry: PluginInventoryEntry; providers: readonly [AgentPresetGroup, ...AgentPresetGroup[]] }[] = [] const regularEntries: PluginInventoryEntry[] = [] for (const entry of entries) { - const providers = enabledIn.get(entry.moduleName) if (entry.fiberPhase === 'failed') failedEntries.push(entry) - else if (!entry.enabled && providers !== undefined) drawerEntries.push({ entry, providers }) else regularEntries.push(entry) } const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery) const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery) const filteredFailed = failedEntries.filter(entryMatch) - const filteredDrawer = drawerEntries.filter(drawerRow => entryMatch(drawerRow.entry)) const filteredRegular = regularEntries.filter(entryMatch) - const globalCount = filteredFailed.length + filteredDrawer.length + filteredRegular.length + const globalCount = filteredFailed.length + filteredRegular.length const selectedRows = selected === undefined ? [] : selected.rows.filter(rowMatch) const otherPresetMatches = searching ? presets.filter(preset => preset !== selected && preset.rows.some(rowMatch)) @@ -224,8 +222,8 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT const otherMatchCount = otherPresetMatches .reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0) + const presetEffectiveOpen = searching || (presetOpen ?? true) const globalEffectiveOpen = searching || (globalOpen ?? presets.length === 0) - const drawerEffectiveOpen = searching || drawerOpen const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0 && otherPresetMatches.length === 0 @@ -258,7 +256,9 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT ariaLabel={`${title}, ${stateText}`} trailing={( <> - {row.enabled === true && !failed ? : null} + {row.enabled === true && !failed && row.fiberPhase !== null + ? + : null} )} @@ -278,12 +278,12 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT ) } - /** One global-plane row; a drawer row carries the presets that enable it. */ + /** One global-plane row; a preset-provided row carries the presets that enable it. */ const globalRowCard = ( entry: PluginInventoryEntry, providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]], ): ReactNode => { - const key = `${providers === undefined ? 'global' : 'drawer'}:${entry.entryId}` + const key = `global:${entry.entryId}` const title = moduleShortName(entry.moduleName) const failed = entry.fiberPhase === 'failed' const stateText = failed @@ -302,7 +302,9 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT ariaLabel={`${title}, ${stateText}`} trailing={( <> - {entry.enabled && !failed ? : null} + {entry.enabled && !failed && entry.fiberPhase !== null + ? + : null} )} @@ -313,7 +315,7 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT entryId={entry.entryId} facts={providers !== undefined ? [ - [t('configuration'), t('drawerDetail')], + [t('configuration'), t('presetProvidedDetail')], [t('enabledIn'), ( {providers.map(preset => preset.name ?? preset.id).join(' · ')} @@ -364,43 +366,71 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT {selected !== undefined ? (
    - +
    - {selected.broken !== undefined ? ( -

    {selected.broken}

    - ) : null} - {selectedRows.length > 0 ? ( -
      - {selectedRows.map((row, index) => presetRowCard(selected, row, index))} -
    - ) : null} - {otherMatchCount > 0 ? ( -

    - {t('matchesInOtherPresets', { count: String(otherMatchCount) })} - {otherPresetMatches.map(preset => ( - - ))} -

    + {presetEffectiveOpen ? ( +
    + {selected.broken !== undefined ? ( +

    {selected.broken}

    + ) : null} + {selectedRows.length > 0 ? ( +
      + {selectedRows.map((row, index) => presetRowCard(selected, row, index))} +
    + ) : null} + {otherMatchCount > 0 ? ( +

    + {t('matchesInOtherPresets', { count: String(otherMatchCount) })} + {otherPresetMatches.map(preset => ( + + ))} +

    + ) : null} +
    ) : null}
    ) : null} @@ -422,36 +452,14 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT {filteredFailed.length} {t('failedCountLabel')} ) : null} - {globalEffectiveOpen ? ( -
    - {filteredFailed.length + filteredRegular.length > 0 ? ( -
      - {filteredFailed.map(entry => globalRowCard(entry))} - {filteredRegular.map(entry => globalRowCard(entry))} -
    - ) : null} - {drawerEntries.length > 0 ? ( -
    - - {drawerEffectiveOpen && filteredDrawer.length > 0 ? ( -
      - {filteredDrawer.map(drawerRow => globalRowCard(drawerRow.entry, drawerRow.providers))} -
    - ) : null} -
    - ) : null} -
    + {globalEffectiveOpen && globalCount > 0 ? ( +
      + {filteredFailed.map(entry => globalRowCard(entry))} + {filteredRegular.map(entry => globalRowCard( + entry, + entry.enabled ? undefined : enabledIn.get(entry.moduleName), + ))} +
    ) : null} ) : null} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 3857204a93..8bad8a698e 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -15,9 +15,7 @@ export const zh = { presetOptionBroken: '{name}(加载失败)', globalTitle: '全局', globalSubtitle: '系统与所有会话共用', - drawerTitle: '会话插件', - drawerSubtitle: '不在全局运行,由 Agent 预设按会话提供', - drawerDetail: '全局已停用,由 Agent 预设按会话提供', + presetProvidedDetail: '全局已停用,由 Agent 预设按会话提供', enabledIn: '启用于', viewInPreset: '去预设分组查看', matchesInOtherPresets: '其他预设中还有 {count} 个匹配:', @@ -58,9 +56,7 @@ export const en = { presetOptionBroken: '{name} (failed to load)', globalTitle: 'Global', globalSubtitle: 'Shared by the system and every session', - drawerTitle: 'Session plugins', - drawerSubtitle: 'Not running globally; agent presets provide them per session', - drawerDetail: 'Disabled globally; agent presets provide it per session', + presetProvidedDetail: 'Disabled globally; agent presets provide it per session', enabledIn: 'Enabled in', viewInPreset: 'View in the preset group', matchesInOtherPresets: '{count} more matches in other presets: ', diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 3310d87e6d..6a3702d7ae 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -76,20 +76,21 @@ async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) }) -const drawerToggle = (): HTMLElement => - screen.getByRole('button', { name: (name: string) => name.startsWith(en.drawerTitle) }) describe('PluginInventorySettingsTab', () => { it('shows the default preset first and keeps the global plane collapsed', async () => { const view = await renderReady() - const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) - expect((switcher as HTMLSelectElement).value).toBe('standard') - expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([ + const switcher = screen.getByRole('button', { name: en.switcherLabel }) + expect(switcher.textContent).toBe('标准模式 (default)') + fireEvent.click(switcher) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ '标准模式 (default)', 'ptc', '坏预设 (failed to load)', ]) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryAllByRole('menuitem')).toHaveLength(0) expect(screen.getByText(en.presetSubtitle)).toBeTruthy() expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6') @@ -100,7 +101,8 @@ describe('PluginInventorySettingsTab', () => { expect(screen.getByText(en.disabledTag)).toBeTruthy() expect(screen.getByText(en.failedTag)).toBeTruthy() expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy() - expect(screen.getAllByRole('img', { name: 'Not running' })).toHaveLength(2) + // No live fiber, no dot: file-state rows carry only their enablement tag. + expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull() expect(globalToggle().getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') @@ -126,9 +128,10 @@ describe('PluginInventorySettingsTab', () => { expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous') }) - it('expands the global plane with failures first and the session-plugin drawer', async () => { + it('expands the global plane with failures first and preset-provided rows inline', async () => { const view = await renderReady() + expect(screen.queryByText(en.presetEnabledTag)).toBeNull() fireEvent.click(globalToggle()) expect(globalToggle().getAttribute('aria-expanded')).toBe('true') const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]') @@ -136,14 +139,11 @@ describe('PluginInventorySettingsTab', () => { // Failures float above the Loader-ordered remainder. expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed) - // The drawer stays collapsed until opened, then names its providers. - expect(drawerToggle().getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText(en.presetEnabledTag)).toBeNull() - fireEvent.click(drawerToggle()) + // Rows the presets took over sit inline, marked instead of plainly disabled. expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2) fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' })) - expect(screen.getByText(en.drawerDetail)).toBeTruthy() + expect(screen.getByText(en.presetProvidedDetail)).toBeTruthy() expect(screen.getByText(en.enabledIn)).toBeTruthy() expect(screen.getByText('标准模式 · ptc')).toBeTruthy() @@ -151,41 +151,68 @@ describe('PluginInventorySettingsTab', () => { fireEvent.click(screen.getByRole('button', { name: 'telemetry, Failed' })) expect(screen.getByText('Failed to start')).toBeTruthy() + // An enabled entry with no live fiber says so in its details, dot-free. + fireEvent.click(screen.getByRole('button', { name: 'unobserved-name, Enabled' })) + expect(screen.getByText('Not running')).toBeTruthy() + // A disabled row outside every preset stays plainly disabled. fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' })) - expect(screen.queryByText(en.drawerDetail)).toBeNull() + expect(screen.queryByText(en.presetProvidedDetail)).toBeNull() - fireEvent.click(drawerToggle()) - expect(screen.queryByText(en.presetEnabledTag)).toBeNull() fireEvent.click(globalToggle()) expect(globalToggle().getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(en.presetEnabledTag)).toBeNull() }) it('switches the inspected preset in place, including broken ones', async () => { const view = await renderReady() - const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + const pickPreset = (label: string): void => { + fireEvent.click(screen.getByRole('button', { name: en.switcherLabel })) + fireEvent.click(screen.getByRole('menuitem', { name: label })) + } - fireEvent.change(switcher, { target: { value: 'ptc' } }) + pickPreset('ptc') expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('3') fireEvent.click(screen.getAllByRole('button', { name: 'tool-bash, Enabled' })[0]!) // An unnamed preset labels provenance by its id. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc') - fireEvent.change(switcher, { target: { value: 'shattered' } }) + pickPreset('坏预设 (failed to load)') expect(screen.getByRole('alert').textContent).toBe('the composition file is missing') expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') }) - it('jumps from a drawer row to the preset that enables it', async () => { + it('collapses the preset group until a search forces it open', async () => { + const view = await renderReady() + const toggle = screen.getByRole('button', { name: en.presetSubtitle }) + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + // The header keeps its count while the rows are folded away. + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6') + expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) + + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText(en.conditionalTag)).toBeTruthy() + + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '' } }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) + + it('jumps from a preset-provided row to the preset that enables it', async () => { await renderReady() - const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) - fireEvent.change(switcher, { target: { value: 'ptc' } }) + fireEvent.click(screen.getByRole('button', { name: en.switcherLabel })) + fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' })) fireEvent.click(globalToggle()) - fireEvent.click(drawerToggle()) fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' })) fireEvent.click(screen.getByRole('button', { name: en.viewInPreset })) - expect((switcher as HTMLSelectElement).value).toBe('standard') + expect(screen.getByRole('button', { name: en.switcherLabel }).textContent) + .toBe('标准模式 (default)') }) it('searches across scopes and points at matches in other presets', async () => { @@ -201,7 +228,7 @@ describe('PluginInventorySettingsTab', () => { const hint = screen.getByText((text: string) => text.startsWith('2 more matches')) expect(hint).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'ptc' })) - expect(screen.getByRole('combobox', { name: en.switcherLabel }).value).toBe('ptc') + expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('ptc') // A match visible only in another preset keeps the pointer without rows. fireEvent.change(search, { target: { value: 'crashy' } }) @@ -227,7 +254,7 @@ describe('PluginInventorySettingsTab', () => { ], } as unknown as Snapshot) - expect(screen.queryByRole('combobox', { name: en.switcherLabel })).toBeNull() + expect(screen.queryByRole('button', { name: en.switcherLabel })).toBeNull() expect(globalToggle().getAttribute('aria-expanded')).toBe('true') expect(screen.getAllByRole('listitem')).toHaveLength(2) diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index d9a92d6a27..b1514b3495 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -57,6 +57,14 @@ const harnessBase = new WeakMap() class PresetTree extends Include { constructor(ctx: Context, config: Include.Config) { super(ctx, config) + // EntryTree's constructor files every new tree under the nearest owning + // Loader entry's `subtree` slot — here the roster's own row, because the + // standing scope descends from the roster's fiber. Left in place, root + // `loader.entries()` would walk this composition as host entries (each + // preset overwriting the last), against the standing mount's contract of + // not being a Loader entry. Reclaim the slot. + const owner = this.ctx.fiber.entry + if (owner?.subtree === this) delete owner.subtree mounted.set(config, { tree: this, fiber: ctx.fiber }) } diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts index 8240ce557f..2fcf872fc2 100644 --- a/packages/preset/agent-presets/tests/composition-inventory.spec.ts +++ b/packages/preset/agent-presets/tests/composition-inventory.spec.ts @@ -289,6 +289,38 @@ describe('AgentPresets.compositionInventory', () => { expect(damaged?.broken).toContain('is missing') }) + it('keeps a standing composition out of the root Loader entries', async () => { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.builtins['agent-presets'] = AgentPresets + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + // The roster itself loads as a Loader entry, the way profiles mount it: + // the standing scope then descends from a fiber that OWNS an entry, which + // is exactly the shape that made EntryTree file the mount under it. + await ctx.loader.create({ + name: 'cordis:agent-presets', + config: { default: 'standard', roots: [SYSTEM_ROOT], includeShippedRoot: false, includeUserRoot: false }, + }) + const before = [...ctx.loader.entries()].map(entry => entry.id) + + await ctx.agents.create({ + sessionId: SessionId('loader-entry-guard'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + // The agent joined a standing composition without the composition + // becoming host Loader entries. + expect([...ctx.loader.entries()].map(entry => entry.id)).toEqual(before) + }) + it('reports a composition that raced discovery as broken instead of dropping it', async () => { const ctx = await harness({ default: 'minimal', From 0f06f973c411c99bc391afdb7bdff20b057c441d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 17:13:45 +0800 Subject: [PATCH 087/429] refactor(plugin-inventory): settings-row style group headers Both scope groups drop their boxed chrome for the General-settings row idiom: a title row (session-plugins title with the right-aligned preset selector pill; global-plugins title), a grey subtitle line carrying the count, a hairline divider between groups, and the cards grid below. Counts ride the subtitle as text while the data attributes keep the raw numbers for tests. --- .../PluginInventorySettingsTab.module.css | 54 +++++------ .../src/client/PluginInventorySettingsTab.tsx | 95 ++++++++++--------- .../src/client/locales.ts | 12 ++- .../tests/components.client.spec.tsx | 20 ++-- 4 files changed, 94 insertions(+), 87 deletions(-) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 60673d222f..fa312a68fa 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -225,21 +225,29 @@ display: flex; flex-direction: column; gap: 10px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - padding: 10px 12px 12px; } -.groupHeader, -.groupToggle { +.groupTitleRow { display: flex; align-items: center; gap: 8px; - min-height: 28px; + min-height: 32px; +} + +.group + .group { + border-top: 1px solid var(--dsw-alias-border-l2); + padding-top: 14px; +} + +.headerEnd { + margin-left: auto; } .groupToggle { - width: 100%; + display: flex; + flex: none; + align-items: center; + gap: 8px; border: 0; padding: 0; background: transparent; @@ -263,22 +271,17 @@ } .groupTitle { - font-size: 13px; - line-height: 20px; - font-weight: 600; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); } -.groupSubtitle { - overflow: hidden; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.groupCount { - margin-left: auto; +.groupSub { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + margin: -6px 0 0 20px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; @@ -286,10 +289,7 @@ } .failedCount { - flex: none; color: var(--dsw-alias-state-error-primary); - font-size: 12px; - line-height: 18px; } /* The preset switcher trigger mirrors the General-settings selector pill. */ @@ -331,12 +331,6 @@ text-overflow: ellipsis; } -.iconToggle { - flex: none; - width: auto; - min-height: 0; - padding: 4px; -} .groupBody { display: flex; diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index e56cbfdef8..fa78d36872 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -365,46 +365,51 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT {selected !== undefined ? (
    -
    +
    - { setSwitcherOpen(false) }} - items={presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t) }))} - selectedId={selected.id} - onSelect={(id) => { - setSwitcherOpen(false) - setChosenPreset(id) - }} - portal - anchor={( - - )} - /> - {t('presetSubtitle')} - - {selectedRows.length} - +
    + { setSwitcherOpen(false) }} + items={presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t) }))} + selectedId={selected.id} + onSelect={(id) => { + setSwitcherOpen(false) + setChosenPreset(id) + }} + align="end" + portal + anchor={( + + )} + /> +
    +

    + {t('presetSubtitle')} + + {` · ${String(selectedRows.length)} ${t('countUnit')}`} + +

    {presetEffectiveOpen ? (
    {selected.broken !== undefined ? ( @@ -437,21 +442,25 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT {entries.length > 0 ? (
    - +
    +

    + {t('globalSubtitle')} + {` · ${String(globalCount)} ${t('countUnit')}`} {filteredFailed.length > 0 ? ( {filteredFailed.length} {t('failedCountLabel')} ) : null} - +

    {globalEffectiveOpen && globalCount > 0 ? (
      {filteredFailed.map(entry => globalRowCard(entry))} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 8bad8a698e..603c1ce38b 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -9,11 +9,13 @@ export const zh = { search: '搜索插件', empty: '暂无插件。', emptySearch: '没有匹配的插件。', - presetSubtitle: '会话使用的插件', + presetTitle: '会话插件', + presetSubtitle: '由 Agent 预设按会话组成', + countUnit: '个', switcherLabel: '选择要查看的 Agent 预设', presetOptionDefault: '{name}(默认)', presetOptionBroken: '{name}(加载失败)', - globalTitle: '全局', + globalTitle: '全局插件', globalSubtitle: '系统与所有会话共用', presetProvidedDetail: '全局已停用,由 Agent 预设按会话提供', enabledIn: '启用于', @@ -50,11 +52,13 @@ export const en = { search: 'Search plugins', empty: 'No plugins are available.', emptySearch: 'No matching plugins.', - presetSubtitle: 'Plugins your sessions run', + presetTitle: 'Session plugins', + presetSubtitle: 'Composed per session by agent presets', + countUnit: 'plugins', switcherLabel: 'Choose the agent preset to inspect', presetOptionDefault: '{name} (default)', presetOptionBroken: '{name} (failed to load)', - globalTitle: 'Global', + globalTitle: 'Global plugins', globalSubtitle: 'Shared by the system and every session', presetProvidedDetail: 'Disabled globally; agent presets provide it per session', enabledIn: 'Enabled in', diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 6a3702d7ae..69ce356a49 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -92,7 +92,7 @@ describe('PluginInventorySettingsTab', () => { fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryAllByRole('menuitem')).toHaveLength(0) expect(screen.getByText(en.presetSubtitle)).toBeTruthy() - expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') // Only the preset group lists rows while the global plane stays collapsed. expect(screen.getAllByRole('listitem')).toHaveLength(6) @@ -105,7 +105,7 @@ describe('PluginInventorySettingsTab', () => { expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull() expect(globalToggle().getAttribute('aria-expanded')).toBe('false') - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') + expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7') expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy() // A preset row expands into its provenance facts. @@ -172,25 +172,25 @@ describe('PluginInventorySettingsTab', () => { } pickPreset('ptc') - expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('3') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3') fireEvent.click(screen.getAllByRole('button', { name: 'tool-bash, Enabled' })[0]!) // An unnamed preset labels provenance by its id. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc') pickPreset('坏预设 (failed to load)') expect(screen.getByRole('alert').textContent).toBe('the composition file is missing') - expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0') }) it('collapses the preset group until a search forces it open', async () => { const view = await renderReady() - const toggle = screen.getByRole('button', { name: en.presetSubtitle }) + const toggle = screen.getByRole('button', { name: en.presetTitle }) expect(toggle.getAttribute('aria-expanded')).toBe('true') fireEvent.click(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('false') // The header keeps its count while the rows are folded away. - expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } }) @@ -221,8 +221,8 @@ describe('PluginInventorySettingsTab', () => { fireEvent.change(search, { target: { value: 'tool-bash' } }) // Searching forces the collapsed global plane and drawer open. - expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('1') - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('1') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('1') + expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1') expect(screen.getByText(en.presetEnabledTag)).toBeTruthy() expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull() const hint = screen.getByText((text: string) => text.startsWith('2 more matches')) @@ -232,13 +232,13 @@ describe('PluginInventorySettingsTab', () => { // A match visible only in another preset keeps the pointer without rows. fireEvent.change(search, { target: { value: 'crashy' } }) - expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0') expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy() expect(screen.queryByText(en.emptySearch)).toBeNull() // A match on a Loader entry id only reaches the global plane. fireEvent.change(search, { target: { value: '8a1b2c3d' } }) - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('1') + expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1') expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull() fireEvent.change(search, { target: { value: 'not-a-plugin' } }) From cebd0a2031dbb6901802df9e862b8b7792315ba8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 17:26:05 +0800 Subject: [PATCH 088/429] refactor(plugin-inventory): match group title and switcher pill to the General-settings row idiom --- .../client/PluginInventorySettingsTab.module.css | 15 +++++++-------- .../src/client/PluginInventorySettingsTab.tsx | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index fa312a68fa..3a1626a4f0 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -273,7 +273,7 @@ .groupTitle { font-size: 14px; line-height: 22px; - font-weight: 500; + font-weight: 400; color: var(--dsw-alias-label-primary); } @@ -297,18 +297,17 @@ display: inline-flex; flex: none; align-items: center; - gap: 10px; - height: 30px; + gap: 12px; + height: 36px; white-space: nowrap; border: none; - border-radius: 15px; - padding: 0 12px; + border-radius: 18px; + padding: 0 14px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-primary); font: inherit; - font-size: 13px; - line-height: 20px; - font-weight: 600; + font-size: 14px; + line-height: 22px; cursor: pointer; } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index fa78d36872..9e6bb7f951 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -398,7 +398,7 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT onClick={() => { setSwitcherOpen(value => !value) }} > {presetLabel(selected, t)} -