From 7c38fd8102be7a8a36adf63e8049afd265acda46 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 15:30:35 +0800 Subject: [PATCH 01/65] 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 02/65] 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 03/65] 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 04/65] 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 05/65] 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 06/65] 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 07/65] =?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 2c1cc3e7781b0b83dda2418bac92d17b429dd425 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 19:37:25 +0800 Subject: [PATCH 08/65] 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 09/65] 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 10/65] 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 11/65] 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 12/65] 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 13/65] 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 14/65] 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 15/65] ci: retrigger pull request workflows From 61f65ffd48d42f14c32d6ceef3b837d86c1332f4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 23:27:14 +0800 Subject: [PATCH 16/65] 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 206fb8b53ceaa9538d07f283342a9ce8f111cda3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 27 Aug 2026 23:20:38 +0800 Subject: [PATCH 17/65] 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 7222e17dc03daf46b58eedbbdeace107a258ff0b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 28 Aug 2026 15:02:04 +0800 Subject: [PATCH 18/65] 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 aaa692033b8c98cc69701eb52ff6600eaaea167e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 28 Aug 2026 15:16:20 +0800 Subject: [PATCH 19/65] 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 20/65] 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 21/65] 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 51a6eabb2796209d2975ea08145ed76336a71598 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 28 Aug 2026 17:36:55 +0800 Subject: [PATCH 22/65] 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 143748bae95644cda5fc091557e778eef4aa366c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 28 Aug 2026 18:02:11 +0800 Subject: [PATCH 23/65] 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 d5a9e5b274590a763a498d8008f6a1d94e428914 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 16:52:39 +0800 Subject: [PATCH 24/65] 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 e5f36cc70fd63fe4e8ca12ef8d4723eee861e8f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 17:13:45 +0800 Subject: [PATCH 25/65] 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 26/65] 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 27/65] 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 28/65] 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)} -