From e199aa1bc7a8ad15fbbd3ecdaf9ce7958a568300 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 15:01:22 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor(cordis):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=AE=BF=E9=97=AE=E4=B8=8E=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cordis-api/fiber.i18n.yaml | 21 +++-- docs/cordis-api/fiber.md | 53 ++++++++++++- docs/cordis-api/fiber.zh.md | 56 +++++++++++++- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- .../boot/app-boot/tests/config-reload.spec.ts | 4 +- .../boot/app-boot/tests/user-patches.spec.ts | 2 +- packages/boot/config-editor/src/index.ts | 5 +- packages/boot/hmr/src/index.ts | 2 +- packages/boot/hmr/tests/modules.spec.ts | 2 +- .../client/locale/tests/config.host.spec.ts | 6 +- .../tests/collection.host.spec.ts | 2 +- .../client/ui-chat/tests/config.host.spec.ts | 18 ++--- .../ui-conversation/tests/config.host.spec.ts | 4 +- .../tests/config.host.spec.ts | 2 +- .../ui-settings/tests/config.host.spec.ts | 4 +- .../client/ui-theme/tests/config.host.spec.ts | 6 +- .../experimental/translator/src/deepseek.ts | 4 +- .../translator/tests/deepseek.spec.ts | 10 +-- packages/settings/settings/src/index.ts | 2 +- .../settings/tests/configuration.spec.ts | 4 +- .../settings/settings/tests/live-config.ts | 4 +- scripts/cordis-config-access.spec.ts | 76 +++++++++++++++++++ scripts/loader-volatile-update.spec.ts | 64 ++++++++-------- vendor/README.md | 4 +- vendor/cordis/README.md | 6 ++ vendor/cordis/src/fiber.ts | 37 +++++++-- vendor/hmr/src/index.ts | 2 +- vendor/loader/src/config/entry.ts | 10 +-- 29 files changed, 314 insertions(+), 100 deletions(-) create mode 100644 scripts/cordis-config-access.spec.ts diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index acc84e2f3a..6c1d8987ce 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -50,15 +50,24 @@ /fiber/the-fiber-class/fiber-geteffects: en: 4d739a9c186fd9c2 zh: f80fd3b87e79b004 +/fiber/the-fiber-class/fiber-rawconfig: + en: 898aab51825bd8c3 + zh: b9ab6339574002df +/fiber/the-fiber-class/fiber-effectiveconfig: + en: 93ac680251e7023f + zh: 8ea7d7de5db39b8e +/fiber/the-fiber-class/fiber-resolveconfig-config: + en: d7319852e2a1c3a9 + zh: 3300fac049227006 /fiber/the-fiber-class/fiber-await: - en: 1d6258cbea61cd18 - zh: 2f10dda8918dadc5 + en: c9a6544cacdbc28e + zh: 636bf93efad95bc9 /fiber/the-fiber-class/fiber-restart: - en: 62d68dedb20b4fa9 - zh: 5a735ae0ff1266f4 + en: 6ffc1baba98d689f + zh: 7e13bc92225c7e0a /fiber/the-fiber-class/fiber-update-config-nosave: - en: b57ce0f6260c2cb8 - zh: 3bfb6c5bc252a2c2 + en: 290f7f1bf1207adc + zh: e666cfb53dea897a /fiber/effect: en: 007d5b214e8a8610 zh: d74c26fbb1b8aeb6 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 3044301eff..366c457d4f 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -209,6 +209,53 @@ Return metadata for currently registered effects. [Source](../../vendor/cordis/src/fiber.ts#L568) +### fiber.rawConfig + +```ts cordis-catalog +/** Raw input retained for the next activation, without evaluating expressions or validating it. */ +get rawConfig(): unknown +``` + +Raw input retained for the next activation, without evaluating expressions or validating it. + +[Source](../../vendor/cordis/src/fiber.ts#L642) + +### fiber.effectiveConfig + +```ts cordis-catalog +/** + * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. + * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + */ +get effectiveConfig(): unknown +``` + +The config object published by the existing activation/update lifecycle, without cloning or re-parsing. It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + +[Source](../../vendor/cordis/src/fiber.ts#L655) + +### fiber.resolveConfig(config) + +```ts cordis-catalog +/** + * Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. + * Does not publish the result, replace raw input, or commit running volatile references. + * Required injections must be available when a hook uses them. + * @param config Raw candidate input. + * @returns The candidate's resolved config, retaining the schema's result identity. + * @throws {ValidationError} when the runtime schema rejects the candidate; hook errors propagate. + */ +resolveConfig(config: unknown): unknown +``` + +Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. Does not publish the result, replace raw input, or commit running volatile references. Required injections must be available when a hook uses them. + +- `config` — Raw candidate input. + +**Returns** The candidate's resolved config, retaining the schema's result identity. + +[Source](../../vendor/cordis/src/fiber.ts#L667) + ### fiber.await() ```ts cordis-catalog @@ -225,7 +272,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L704) +[Source](../../vendor/cordis/src/fiber.ts#L731) ### fiber.restart() @@ -243,7 +290,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L718) +[Source](../../vendor/cordis/src/fiber.ts#L745) ### fiber.update(config, noSave?) @@ -271,7 +318,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../vendor/cordis/src/fiber.ts#L736) +[Source](../../vendor/cordis/src/fiber.ts#L763) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index bee2f523f1..05ee483bdd 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -211,6 +211,56 @@ getEffects() [源码](../../vendor/cordis/src/fiber.ts#L568) +### fiber.rawConfig + +```ts cordis-catalog +/** Raw input retained for the next activation, without evaluating expressions or validating it. */ +get rawConfig(): unknown +``` + + +保留供下次激活使用的原始输入,不执行表达式求值或验证。 + +[源码](../../vendor/cordis/src/fiber.ts#L642) + +### fiber.effectiveConfig + +```ts cordis-catalog +/** + * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. + * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + */ +get effectiveConfig(): unknown +``` + + +既有激活/更新生命周期发布的配置对象,不克隆或重新解析。首次解析前为 undefined,卸载后仍可读取;需要运行实例的调用方应检查 `state`。 + +[源码](../../vendor/cordis/src/fiber.ts#L655) + +### fiber.resolveConfig(config) + +```ts cordis-catalog +/** + * Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. + * Does not publish the result, replace raw input, or commit running volatile references. + * Required injections must be available when a hook uses them. + * @param config Raw candidate input. + * @returns The candidate's resolved config, retaining the schema's result identity. + * @throws {ValidationError} when the runtime schema rejects the candidate; hook errors propagate. + */ +resolveConfig(config: unknown): unknown +``` + + +通过此 fiber 的 `internal/config` 钩子和运行时 schema 对候选值求值并验证。此操作不发布结果、不替换原始输入,也不提交运行中的 volatile 引用。钩子访问的注入服务必须已经可用。 + +- `config`:原始候选输入。 + +**返回**候选配置的解析结果,保留 schema 返回对象的身份。 + +[源码](../../vendor/cordis/src/fiber.ts#L667) + ### fiber.await() ```ts cordis-catalog @@ -227,7 +277,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L704) +[源码](../../vendor/cordis/src/fiber.ts#L731) ### fiber.restart() @@ -245,7 +295,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L718) +[源码](../../vendor/cordis/src/fiber.ts#L745) ### fiber.update(config, noSave?) @@ -273,7 +323,7 @@ update(config: any, noSave = false) **返回**无返回值;重启由 `internal/update` waterfall 执行。 -[源码](../../vendor/cordis/src/fiber.ts#L736) +[源码](../../vendor/cordis/src/fiber.ts#L763) ## Effect diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d727ee4ec8..e18409d8ab 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -96,7 +96,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | +| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | `gateway` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fccfeea698..aed9a07616 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -98,7 +98,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | +| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | `gateway` | diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 4d8dae27e4..f96ddd36c6 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -246,13 +246,13 @@ describe('best-effort config failure recovery', () => { await ctx.loader.await() const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'webserver')! expect(entry.options.config).toEqual({ value: 'invalid' }) - expect(entry.fiber?.config).toEqual({ value: 1 }) + expect(entry.fiber?.effectiveConfig).toEqual({ value: 1 }) expect(ctx.get('validatedValue')).toBe(1) writeFileSync(join(dir, 'cordis.yml'), config(2)) await include.refresh() await ctx.loader.await() - expect(entry.fiber?.config).toEqual({ value: 2 }) + expect(entry.fiber?.effectiveConfig).toEqual({ value: 2 }) expect(ctx.get('validatedValue')).toBe(2) } finally { await ctx.fiber.dispose() diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 09e5653c56..63e0af5ea6 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -451,7 +451,7 @@ describe('boot with user patches', () => { try { const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'user-value' }) + expect(noop?.fiber?.effectiveConfig).toEqual({ value: 'user-value' }) expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true) } finally { await ctx.fiber.dispose() diff --git a/packages/boot/config-editor/src/index.ts b/packages/boot/config-editor/src/index.ts index ffec879f40..8fe686e478 100644 --- a/packages/boot/config-editor/src/index.ts +++ b/packages/boot/config-editor/src/index.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { isDeepStrictEqual } from 'node:util' -import { Context, FiberState, Service, resolveConfig } from '@deepseek-ai/cordis' +import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import yaml from 'js-yaml' import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -100,8 +100,7 @@ export class ConfigEditor extends Service { const next = change(current, inherited) const fiber = entry.fiber if (fiber.state !== FiberState.ACTIVE) throw new Error('Configuration plugin is no longer active') - const resolved: unknown = fiber.ctx.waterfall(fiber, 'internal/config', next, () => next) - resolveConfig(fiber.runtime as NonNullable, resolved) + fiber.resolveConfig(next) let before: string try { before = await readFile(path, 'utf8') } catch (error) { diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index 47d71d9109..5c31921549 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -646,7 +646,7 @@ class Hmr extends Service { ...info, previous, fibers: [...info.runtime?.fibers ?? []].map((fiber) => { const entry = fiber.entry?.fiber?.uid === fiber.uid ? fiber.entry : undefined - const config: unknown = entry === undefined ? fiber._config : entry.options.config + const config: unknown = entry === undefined ? fiber.rawConfig : entry.options.config return { fiber, entry, config, moduleNamespace: entry?.moduleNamespace } }), })) diff --git a/packages/boot/hmr/tests/modules.spec.ts b/packages/boot/hmr/tests/modules.spec.ts index 5cf710ea07..33895a7106 100644 --- a/packages/boot/hmr/tests/modules.spec.ts +++ b/packages/boot/hmr/tests/modules.spec.ts @@ -298,7 +298,7 @@ it('leaves disposed child instances to their replacing parent', async () => { expect(seen).toContain('new:entry') expect(seen).not.toContain('new:child') expect(seen).toContain('new:independent') - expect(entry.fiber?._config).toEqual({ value: 'entry' }) + expect(entry.fiber?.rawConfig).toEqual({ value: 'entry' }) }) it.each(['v1', 'v2'] as const)('invalidates a disabled %s module without activating it', async (version) => { diff --git a/packages/client/locale/tests/config.host.spec.ts b/packages/client/locale/tests/config.host.spec.ts index 9c9d3e5462..84af262287 100644 --- a/packages/client/locale/tests/config.host.spec.ts +++ b/packages/client/locale/tests/config.host.spec.ts @@ -13,11 +13,11 @@ describe('locale host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({}) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({}) await configuration.update({ preference: 'en' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'en' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'en' }) await configuration.update({ preference: 'pt-BR' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'pt-BR' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'pt-BR' }) await expect(configuration.update({ preference: 'bad locale' })).rejects.toThrow() await expect(configuration.update({ preference: '123' })).rejects.toThrow() await fiber.dispose() diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index c7878a8940..8b3cde5d2f 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -134,7 +134,7 @@ it('streams live policy changes and rejects intake disabled during identity look const next = iterator.next() // Loader commits the stable Config reference before publishing this notification. const { updateVolatile, createVolatile } = await import('../../../../vendor/cosmokit/src/volatile.ts') - const config = b.fiber.config as import('../src/index.ts').Config + const config = b.fiber.effectiveConfig as import('../src/index.ts').Config updateVolatile(config.enabled, createVolatile(false)) b.fiber.ctx.emit('loader/volatile-update', [['enabled']]) expect(await next).toEqual({ value: false, done: false }) diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index b3ebb9b698..cfed2e36df 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -14,22 +14,22 @@ describe('ui-chat Host settings', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) await configuration.update({ transcriptView: 'normal' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) { await configuration.update({ transcriptView: mode }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ transcriptView: mode }) } for (const mode of ['dense', '', 42, false, {}, null, undefined]) { await configuration.update({ transcriptView: mode }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode === null ? null : undefined }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ transcriptView: mode === null ? null : undefined }) } await configuration.update({ performanceUsage: 'compact' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ performanceUsage: 'compact' }) await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow() await configuration.update({ linkOpening: 'new-tab' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ linkOpening: 'new-tab' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ linkOpening: 'new-tab' }) await expect(configuration.update({ linkOpening: 'popup' })).rejects.toThrow() await fiber.dispose() @@ -38,15 +38,15 @@ describe('ui-chat Host settings', () => { it('validates collapse timing and restores its saved config independently of work details', async () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ collapseTiming: 'completion' }) await configuration.update({ transcriptView: 'compact', collapseTiming: 'next-input' }) - const saved = plainConfig(configuration.fiber.config) + const saved = plainConfig(configuration.fiber.effectiveConfig) if (typeof saved !== 'object' || saved === null) throw new Error('expected saved configuration object') expect(saved).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) await expect(configuration.update({ collapseTiming: 'never' })).rejects.toThrow() await configuration.fiber.dispose() const restored = await liveConfig(ctx, { Config, apply }, saved) - expect(plainConfig(restored.fiber.config)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) + expect(plainConfig(restored.fiber.effectiveConfig)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) await restored.fiber.dispose() }) diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts index cb047da964..7b87d23d59 100644 --- a/packages/client/ui-conversation/tests/config.host.spec.ts +++ b/packages/client/ui-conversation/tests/config.host.spec.ts @@ -13,9 +13,9 @@ describe('ui-conversation host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) await configuration.update({ busyEnter: 'steer' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ busyEnter: 'steer' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: 'steer' }) await expect(configuration.update({ busyEnter: 'invalid' })).rejects.toThrow() await fiber.dispose() }) diff --git a/packages/client/ui-settings-general/tests/config.host.spec.ts b/packages/client/ui-settings-general/tests/config.host.spec.ts index 7bc4864b1b..cb999a2d94 100644 --- a/packages/client/ui-settings-general/tests/config.host.spec.ts +++ b/packages/client/ui-settings-general/tests/config.host.spec.ts @@ -11,7 +11,7 @@ it('validates and updates onboarding preferences without remounting', async () = onTestFinished(() => ctx.fiber.dispose()) const configuration = await liveConfig(ctx, General) await configuration.update({ welcomeNoticeVersion: 'v1' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ welcomeNoticeVersion: 'v1' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ welcomeNoticeVersion: 'v1' }) expect(configuration.entry.fiber).toBe(configuration.fiber) }) diff --git a/packages/client/ui-settings/tests/config.host.spec.ts b/packages/client/ui-settings/tests/config.host.spec.ts index 558e6350c1..551f313633 100644 --- a/packages/client/ui-settings/tests/config.host.spec.ts +++ b/packages/client/ui-settings/tests/config.host.spec.ts @@ -9,9 +9,9 @@ it('defaults on, persists valid choices and removes its schema on disposal', asy const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) onTestFinished(() => ctx.fiber.dispose()) - expect(plainConfig(configuration.fiber.config)).toEqual({ enabled: true }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ enabled: true }) await configuration.update({ enabled: false }) - expect(plainConfig(configuration.fiber.config)).toEqual({ enabled: false }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ enabled: false }) await expect(configuration.update({ enabled: 'yes' })).rejects.toThrow() await configuration.fiber.dispose() }) diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 69a10570b8..7eb60f6fb2 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -27,11 +27,11 @@ describe('ui-theme host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14, codeFontSize: 11, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '', terminalFontFamily: '' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14, codeFontSize: 11, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '', terminalFontFamily: '' }) await configuration.update({ preference: 'dark', fontSize: 10, codeFontSize: 16, codeFontFamily: '"Iosevka"' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 10, codeFontSize: 16, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '"Iosevka"', terminalFontFamily: '' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'dark', fontSize: 10, codeFontSize: 16, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '"Iosevka"', terminalFontFamily: '' }) await configuration.update({ fontSize: 22 }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ preference: 'dark', fontSize: 22 }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ preference: 'dark', fontSize: 22 }) await expect(configuration.update({ preference: 'sepia' })).rejects.toThrow() await expect(configuration.update({ fontSize: 9 })).rejects.toThrow() await expect(configuration.update({ fontSize: 23 })).rejects.toThrow() diff --git a/packages/experimental/translator/src/deepseek.ts b/packages/experimental/translator/src/deepseek.ts index a63da716a2..afdba4de20 100644 --- a/packages/experimental/translator/src/deepseek.ts +++ b/packages/experimental/translator/src/deepseek.ts @@ -72,8 +72,8 @@ function nativeOwner(ctx: Context, provider: PaidTranslationProvider): NativeOwn const entry = [...loader.entries()].find(row => row.options.id === directory.settingsNs && row.options.name === OWNER_PACKAGES[provider]) if (entry?.fiber?.state !== FiberState.ACTIVE) return undefined return provider === 'deepseek-account' - ? { llm, fiber: entry.fiber, provider, config: entry.fiber.config as AccountNativeConfig } - : { llm, fiber: entry.fiber, provider, config: entry.fiber.config as OfficialNativeConfig } + ? { llm, fiber: entry.fiber, provider, config: entry.fiber.effectiveConfig as AccountNativeConfig } + : { llm, fiber: entry.fiber, provider, config: entry.fiber.effectiveConfig as OfficialNativeConfig } } function unchanged(ctx: Context, provider: PaidTranslationProvider, previous: EligibleOwner): NativeOwner | undefined { diff --git a/packages/experimental/translator/tests/deepseek.spec.ts b/packages/experimental/translator/tests/deepseek.spec.ts index 5ce3434528..7f45aecd9f 100644 --- a/packages/experimental/translator/tests/deepseek.spec.ts +++ b/packages/experimental/translator/tests/deepseek.spec.ts @@ -179,7 +179,7 @@ describe('native Flash translation', () => { before.resolve(undefined) await continueLookup.promise const owner = [...b.ctx.loader.entries()].find(entry => entry.options.id === 'official-custom-id')! - const config = owner.fiber!.config as ReturnType + const config = owner.fiber!.effectiveConfig as ReturnType config.models = OfficialConfig({ models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }).models return models }) @@ -200,7 +200,7 @@ describe('native Flash translation', () => { vi.spyOn(b.ctx.llm, 'listConfigurableProviders').mockImplementation(() => { reads += 1 if (reads === 2) queueMicrotask(() => { - const config = owner.fiber!.config as ReturnType + const config = owner.fiber!.effectiveConfig as ReturnType config.models = OfficialConfig({ models: [{ id: 'deepseek-v4-flash' }] }).models }) return directory @@ -274,7 +274,7 @@ describe('native Flash translation', () => { await owner.fiber!.dispose() await rejected } else if (change === 'catalog') { - const config = owner.fiber!.config as ReturnType + const config = owner.fiber!.effectiveConfig as ReturnType config.models = OfficialConfig({ models: [{ id: 'deepseek-v4-flash' }] }).models } else b.setConfigured(false) release.resolve(undefined) @@ -293,7 +293,7 @@ describe('native Flash translation', () => { const rejected = expect(b.translate('deepseek-account')).rejects.toMatchObject({ code: 'TRANSLATION_UNAVAILABLE' }) await entered.promise const owner = [...b.ctx.loader.entries()].find(entry => entry.options.id === 'account-custom-id')! - const config = owner.fiber!.config as ReturnType + const config = owner.fiber!.effectiveConfig as ReturnType config.modelOverrides = AccountConfig({ modelOverrides: { 'deepseek-flash': { maxTokens: 4096 } } }).modelOverrides release.resolve(undefined) await rejected @@ -309,7 +309,7 @@ describe('native Flash translation', () => { if (pluginRecordOf(event)?.type !== 'plugin:translator/request') return if (change === 'withdrawn') withdrawal = owner.fiber!.dispose() else if (change === 'catalog') { - const config = owner.fiber!.config as ReturnType + const config = owner.fiber!.effectiveConfig as ReturnType config.models = OfficialConfig({ models: [{ id: 'deepseek-v4-flash' }] }).models } else b.setConfigured(false) }, { global: true }) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index e0586ecb24..bd439b2301 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -316,7 +316,7 @@ export class SettingsForms extends Service { if (previous?.raw !== raw || previous.autoGenerate !== autoGenerate) { this.ownerContext.emit('settings/document-updated', entry.options.id as SettingsNamespace, revision) } - const value = projectForm(form, plainConfig(entry.fiber.config)) + const value = projectForm(form, plainConfig(entry.fiber.effectiveConfig)) const resolved: unknown = interpolate(entry.fiber.ctx, inherited) const base = projectForm(form, plainConfig(inheritedConfig(entry.fiber.runtime, resolved))) const user = projectForm(form, override) diff --git a/packages/settings/settings/tests/configuration.spec.ts b/packages/settings/settings/tests/configuration.spec.ts index 5bafea1e5b..6fd9b9fe13 100644 --- a/packages/settings/settings/tests/configuration.spec.ts +++ b/packages/settings/settings/tests/configuration.spec.ts @@ -70,9 +70,9 @@ it('preserves configuration expressions while editing a different live field', a await ctx.fiber.dispose() const restored = await start() const entry = [...restored.loader.entries()].find(row => row.options.id === 'first')! - expect((entry.fiber!.config as { ordinary: string }).ordinary).toBe('expression-value') + expect((entry.fiber!.effectiveConfig as { ordinary: string }).ordinary).toBe('expression-value') const second = [...restored.loader.entries()].find(entry => entry.options.id === 'second')! - expect((second.fiber!.config as { ordinary: string }).ordinary).toBe('untouched-expression') + expect((second.fiber!.effectiveConfig as { ordinary: string }).ordinary).toBe('untouched-expression') expect(restored.settings.describe().find(row => row.ns === 'first')!.value).toEqual({ count: 4, list: [] }) }) diff --git a/packages/settings/settings/tests/live-config.ts b/packages/settings/settings/tests/live-config.ts index 1248487f55..551ea7c107 100644 --- a/packages/settings/settings/tests/live-config.ts +++ b/packages/settings/settings/tests/live-config.ts @@ -1,5 +1,5 @@ /** Test fixtures using the same Loader updates as profile reconciliation. */ -import { Context, resolveConfig, type Fiber, type Plugin } from '@deepseek-ai/cordis' +import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis' import { expect, vi } from 'vitest' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -27,7 +27,7 @@ export async function liveConfig(ctx: Context, plugin: Plugin, initial: object = await entry.fiber!.await() const replace = async (next: Record) => { const fiber = entry.fiber! - resolveConfig(fiber.runtime!, fiber.ctx.waterfall(fiber, 'internal/config', next, () => next)) + fiber.resolveConfig(next) await entry.update({ config: next }) await entry.fiber!.await() ctx.emit('app-boot/config-reload') diff --git a/scripts/cordis-config-access.spec.ts b/scripts/cordis-config-access.spec.ts new file mode 100644 index 0000000000..16457afdec --- /dev/null +++ b/scripts/cordis-config-access.spec.ts @@ -0,0 +1,76 @@ +/** Raw retention and candidate evaluation preserve the running plugin's config identity. */ +import { Context, type Fiber } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import z from '@deepseek-ai/schemastery' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +function root(): Context { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + return ctx +} + +describe('Fiber configuration access', () => { + it('keeps raw and running references unchanged while resolving valid or invalid candidates', async () => { + const ctx = root() + await ctx.plugin(Loader) + const Config = z.object({ value: z.number().min(0).volatile() }) + const applied = vi.fn() + ctx.loader.builtins.consumer = { Config, apply: applied } + const raw = { value: { __jsExpr: '1 + 1' } } + const id = await ctx.loader.create({ name: 'cordis:consumer', config: raw }) + const fiber = ctx.loader.resolve(id).fiber! + await fiber.await() + const running = applied.mock.calls[0]![1] as ReturnType + expect(fiber.rawConfig).toBe(raw) + expect(fiber.effectiveConfig).toBe(running) + const candidate = fiber.resolveConfig({ value: { __jsExpr: '2 + 2' } }) as ReturnType + expect(candidate.value.get()).toBe(4) + expect(candidate.value).not.toBe(running.value) + expect(() => fiber.resolveConfig({ value: -1 })).toThrow() + expect(fiber.rawConfig).toBe(raw) + expect(fiber.effectiveConfig).toBe(running) + expect(running.value.get()).toBe(2) + expect(applied).toHaveBeenCalledOnce() + }) + + it('uses the canonical fiber through registry wrappers and retains raw input without updating', async () => { + const ctx = root() + const initial = { value: 1 } + const apply = vi.fn() + const wrapper = ctx.plugin(apply, initial) + await wrapper + const fiber = wrapper.ctx.fiber + const hook = vi.fn(function (this: Fiber, _config: unknown, next: () => unknown) { + expect(this).toBe(fiber) + return next() + }) + fiber.ctx.on('internal/config', hook) + const raw = { value: 2 } + wrapper.rawConfig = raw + expect(fiber.rawConfig).toBe(raw) + expect(wrapper.effectiveConfig).toBe(initial) + expect(wrapper.resolveConfig(raw)).toBe(raw) + expect(hook).toHaveBeenCalledOnce() + expect(fiber.effectiveConfig).toBe(initial) + expect(apply).toHaveBeenCalledOnce() + await fiber.restart() + expect(fiber.effectiveConfig).toBe(raw) + expect(apply).toHaveBeenCalledTimes(2) + }) + + it('retains a pending raw update until its injection is available', async () => { + const ctx = root() + ctx.provide('configAccessTest') + const apply = vi.fn() + const wrapper = ctx.plugin({ inject: ['configAccessTest'], apply }, { value: 1 }) + const raw = { value: 2 } + wrapper.ctx.fiber.update(raw) + expect(wrapper.rawConfig).toBe(raw) + expect(wrapper.effectiveConfig).toBeUndefined() + ctx.set('configAccessTest', {}) + await wrapper + expect(wrapper.effectiveConfig).toBe(raw) + expect(apply).toHaveBeenCalledOnce() + }) +}) diff --git a/scripts/loader-volatile-update.spec.ts b/scripts/loader-volatile-update.spec.ts index 16d8f3b0a8..c23486cc66 100644 --- a/scripts/loader-volatile-update.spec.ts +++ b/scripts/loader-volatile-update.spec.ts @@ -47,9 +47,9 @@ describe('volatile Loader updates', () => { const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { owner.on('loader/volatile-update', events) } }, { title: 'first' }) - const reference = fiber.config as ReturnType + const reference = fiber.effectiveConfig as ReturnType await update(fiber, { title: 'second' }) - expect(fiber.config).toBe(reference) + expect(fiber.effectiveConfig).toBe(reference) expect(reference.get()).toEqual({ title: 'second' }) expect(events).toHaveBeenCalledExactlyOnceWith([[]]) }) @@ -70,7 +70,7 @@ describe('volatile Loader updates', () => { await update(first, { title: 'next', nested: { count: 2 } }) await first.await() expect(mount).toHaveBeenCalledTimes(2) - expect(first.config).toBe(initial) + expect(first.effectiveConfig).toBe(initial) expect(initial.title).toBe(title) expect(configs[1]!.title.get()).toBe('hello') expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }]) @@ -117,9 +117,9 @@ describe('volatile Loader updates', () => { hooks.push(config.title.get()) }) } }, {}) - const initial = fiber.config as ReturnType + const initial = fiber.effectiveConfig as ReturnType await update(fiber, { title: 'accepted' }) - expect(fiber.config).toBe(initial) + expect(fiber.effectiveConfig).toBe(initial) expect(initial.title.get()).toBe('accepted') await update(fiber, { title: 'accepted' }) expect(hooks).toEqual([]) @@ -180,11 +180,11 @@ describe('volatile Loader updates', () => { const oldUrl = 'https://a.example/' const newUrl = 'https://b.example/' const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl }) - const before = fiber.config as ReturnType + const before = fiber.effectiveConfig as ReturnType await update(fiber, { live: 'b', url: newUrl }) await fiber.await() expect(apply).toHaveBeenCalledTimes(2) - expect((fiber.config as ReturnType).url.href).toBe(newUrl) + expect((fiber.effectiveConfig as ReturnType).url.href).toBe(newUrl) expect(before.live.get()).toBe('a') await update(fiber, { live: 'c', url: newUrl, data: 'first' }) await update(fiber, { live: 'd', url: newUrl, data: 'second' }) @@ -199,7 +199,7 @@ describe('volatile Loader updates', () => { owner.on('loader/volatile-update', events) } }, { values: [undefined] }) await update(fiber, { values: [null] }) - expect((fiber.config as ReturnType).values.get()).toEqual([null]) + expect((fiber.effectiveConfig as ReturnType).values.get()).toEqual([null]) expect(events).toHaveBeenCalledExactlyOnceWith([['values']]) }) }) @@ -308,7 +308,7 @@ it('updates volatile children through Group and Include custom updates and saves expect(includeEntry.fiber).toBe(originalInclude) expect(group.fiber).toBe(originalGroup) expect(consumer.fiber).toBe(originalConsumer) - expect(consumer.fiber!.config).toBe(initial) + expect(consumer.fiber!.effectiveConfig).toBe(initial) expect(configs).toHaveLength(1) expect(notifications).toHaveBeenCalledTimes(2) expect(childUpdates).not.toHaveBeenCalled() @@ -318,7 +318,7 @@ it('updates volatile children through Group and Include custom updates and saves entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' }) expect(childUpdates).toHaveBeenCalledOnce() expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' }) - expect((entry.fiber!.config as ReturnType).title.get()).toBe('hello') + expect((entry.fiber!.effectiveConfig as ReturnType).title.get()).toBe('hello') entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true) expect(childUpdates).toHaveBeenCalledTimes(2) expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' }) @@ -332,11 +332,11 @@ it('commits volatile edits in place and retains direct Fiber updates', async () const notifications = vi.fn() const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) }) const fiber = await mountEntry(ctx, { Config, apply }, {}) - const original = fiber.config as ReturnType + const original = fiber.effectiveConfig as ReturnType await ctx.loader.create({ name: 'cordis:disabled', disabled: true }) await update(fiber, { title: 'enabled' }) expect(apply).toHaveBeenCalledOnce() - expect(fiber.config).toBe(original) + expect(fiber.effectiveConfig).toBe(original) expect(original.title.get()).toBe('enabled') expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']]) await update(fiber, { title: 'invalid', nested: { count: -1 } }) @@ -344,7 +344,7 @@ it('commits volatile edits in place and retains direct Fiber updates', async () await update(fiber, { title: 'mixed', fixed: 'changed' }) expect(apply).toHaveBeenCalledTimes(2) expect(original.title.get()).toBe('enabled') - const current = fiber.config as ReturnType + const current = fiber.effectiveConfig as ReturnType expect(current.title.get()).toBe('mixed') const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() }) @@ -365,7 +365,7 @@ it('parses a volatile-only update through the config hook without patching the e const fiber = await mountEntry(ctx, { Config, apply(owner: Context) { owner.on('loader/volatile-update', () => { trace.push('notify') }) } }, { block: { title: 'initial' } }) - const current = fiber.config as ReturnType + const current = fiber.effectiveConfig as ReturnType const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next()) fiber.ctx.on('internal/config', resolveHook) const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() }) @@ -379,7 +379,7 @@ it('parses a volatile-only update through the config hook without patching the e expect(resolveHook).toHaveBeenCalledOnce() expect(trace).toEqual(['validate', 'notify']) expect(current.block.get()).toEqual({ title: 'changed' }) - expect(fiber._config).toBe(raw) + expect(fiber.rawConfig).toBe(raw) }) it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => { @@ -452,7 +452,7 @@ it.each(['group', 'include'] as const)('applies profile patches to a %s subtree await reconcileProfilePatches(ctx, patch('second'), 'volatile-test') expect(parent.fiber).toBe(parentFiber) expect(entry.fiber).toBe(childFiber) - expect(entry.fiber!.config).toBe(original) + expect(entry.fiber!.effectiveConfig).toBe(original) expect(original.title).toBe(reference) expect(reference.get()).toBe('second') expect(configs).toHaveLength(1) @@ -482,7 +482,7 @@ it('logs an invalid candidate and keeps its references while other entries commi await healthy.entry!.update({ config: { title: 'healthy' } }) expect(warning).toHaveBeenCalledTimes(2) expect((invalid.config as ReturnType).title.get()).toBe('hello') - expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } }) + expect(invalid.rawConfig).toEqual({ title: 'invalid', nested: { count: -1 } }) expect((healthy.config as ReturnType).title.get()).toBe('healthy') expect(events).toHaveBeenCalledExactlyOnceWith([['title']]) }) @@ -500,7 +500,7 @@ it.each([false, true])('retains raw expression edits for dependency restoration await fiber.entry!.update({ config: raw }) await fiber.await() expect(values).toEqual(volatile ? ['same'] : ['same', 'same']) - expect(fiber._config).toBe(raw) + expect(fiber.rawConfig).toBe(raw) await provider.dispose() await provide('old-route', 'new-route') await fiber.await() @@ -540,10 +540,10 @@ it('compares ordinary URL transforms by value and remounts when an expression re const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" } const raw = { url: 'https://example.com/', fixed, live: 'old' } const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw) - const current = fiber.config as ReturnType + const current = fiber.effectiveConfig as ReturnType await update(fiber, { ...raw, live: 'new' }) expect(mount).toHaveBeenCalledOnce() - expect(fiber.config).toBe(current) + expect(fiber.effectiveConfig).toBe(current) expect(current.url.href).toBe(raw.url) expect(current.fixed).toBe('initial') expect(current.live.get()).toBe('new') @@ -554,7 +554,7 @@ it('compares ordinary URL transforms by value and remounts when an expression re expect(current.fixed).toBe('initial') expect(current.live.get()).toBe('new') expect(events).toHaveBeenCalledOnce() - const replaced = fiber.config as ReturnType + const replaced = fiber.effectiveConfig as ReturnType expect(replaced.fixed).toBe('changed') expect(replaced.live.get()).toBe('remounted') }) @@ -568,13 +568,13 @@ it('remounts when an ordinary parent expression removes a live reference path', const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) }) const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' } const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw) - const current = fiber.config as ReturnType + const current = fiber.effectiveConfig as ReturnType source.block = undefined await update(fiber, { ...raw, live: 'new' }) expect(mount).toHaveBeenCalledTimes(2) expect(current.block.value.get()).toBe('old') expect(events).not.toHaveBeenCalled() - expect((fiber.config as ReturnType).block).toBeUndefined() + expect((fiber.effectiveConfig as ReturnType).block).toBeUndefined() }) it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => { @@ -582,13 +582,13 @@ it('keeps an ordinary plugin mounted when an absent object becomes its explicit const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) }) const mount = vi.fn() const fiber = await mountEntry(ctx, { Config, apply: mount }, {}) - const config: unknown = fiber.config + const config: unknown = fiber.effectiveConfig await update(fiber, { nested: { fixed: 'default' } }) - expect(fiber.config).toBe(config) + expect(fiber.effectiveConfig).toBe(config) expect(mount).toHaveBeenCalledTimes(1) await update(fiber, { nested: { fixed: 'changed' } }) expect(mount).toHaveBeenCalledTimes(2) - expect(fiber.config).toEqual({ nested: { fixed: 'changed' } }) + expect(fiber.effectiveConfig).toEqual({ nested: { fixed: 'changed' } }) }) it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => { @@ -596,11 +596,11 @@ it('keeps a noSave volatile value as the pending raw config across a no-op entry const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' }) fiber.update({ title: 'ephemeral' }, true) await fiber.await() - expect((fiber.config as ReturnType).title.get()).toBe('ephemeral') + expect((fiber.effectiveConfig as ReturnType).title.get()).toBe('ephemeral') expect(fiber.entry!.options.config).toEqual({ title: 'saved' }) await fiber.entry!.update({ config: { title: 'saved' } }) - expect(fiber._config).toEqual({ title: 'ephemeral' }) - expect((fiber.config as ReturnType).title.get()).toBe('ephemeral') + expect(fiber.rawConfig).toEqual({ title: 'ephemeral' }) + expect((fiber.effectiveConfig as ReturnType).title.get()).toBe('ephemeral') }) it('isolates a failing notification listener and dispatches with a Context receiver', async () => { @@ -613,7 +613,7 @@ it('isolates a failing notification listener and dispatches with a Context recei const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) onTestFinished(() => { warning.mockRestore() }) await update(fiber, { title: 'committed' }) - expect((fiber.config as ReturnType).title.get()).toBe('committed') + expect((fiber.effectiveConfig as ReturnType).title.get()).toBe('committed') expect(receivers).toEqual([fiber]) expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' })) await update(other, { title: 'unaffected' }) @@ -626,9 +626,9 @@ it('remounts instead of committing when an ordinary field parses into a fresh cl const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() }) const mount = vi.fn() const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' }) - const before = fiber.config as ReturnType + const before = fiber.effectiveConfig as ReturnType await update(fiber, { handle: 'h', live: 'new' }) expect(mount).toHaveBeenCalledTimes(2) expect(before.live.get()).toBe('old') - expect((fiber.config as ReturnType).live.get()).toBe('new') + expect((fiber.effectiveConfig as ReturnType).live.get()).toBe('new') }) diff --git a/vendor/README.md b/vendor/README.md index 654276201d..deff0e6500 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,7 +46,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`loader/src/config/entry.ts` activation observer**: the detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit consumer audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. `Fiber.rawConfig`, `Fiber.resolveConfig(candidate)` and `Fiber.effectiveConfig` centralize raw retention, candidate evaluation and reads of the lifecycle-published object. Accessors use the canonical fiber, including writes through registry wrappers; candidate resolution never publishes or commits references. Loader volatile updates, config editing, settings, translator and both HMR providers use these accessors while the existing Core config fields and event protocol remain in place. Covered by `scripts/cordis-config-access.spec.ts`, `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`. @@ -56,7 +56,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 21. **`cordis/src/logger.ts` exporter disposal**: each disposer retains its registration id, so removing an earlier exporter cannot delete a later console or telemetry exporter. Covered by startup collector cleanup in `packages/boot/app-boot/tests/app-boot.spec.ts` and disabled-feedback output in `packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts`. -22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through the fiber's `internal/config` hook and `resolveConfig`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite. +22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through `Fiber.resolveConfig()`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite. 23. **`loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field. diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md index d0181dbf76..2f7385dcf4 100644 --- a/vendor/cordis/README.md +++ b/vendor/cordis/README.md @@ -103,3 +103,9 @@ docs use public APIs from `cordis`; loader examples additionally use ## Volatile configuration Schemas may return `Volatile` references, whose values are read through `.get()`. Cordis exports the consumer types; Loader commits volatile-only changes into the running references without restarting and notifies the owning fiber through `loader/volatile-update`. Direct `fiber.update()` retains its existing update waterfall and default restart. + +## Configuration access + +`fiber.rawConfig` reads or retains the raw input for a later activation. Assigning it does not validate, update the running instance or trigger a restart. `fiber.resolveConfig(candidate)` evaluates the fiber’s config hooks and schema without publishing the result; any services used by those hooks must already be available. `fiber.effectiveConfig` returns the lifecycle-published object without re-parsing, preserving the identity of volatile references. It is undefined before the first resolution and stays readable after unload, so consumers that require a running instance also check the fiber state. + +Use `fiber.update()` for an ordinary config change. Loader retains raw input separately when committing only volatile values into the running object. diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 6bb1caa7a5..a21f18510d 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -638,9 +638,36 @@ export class Fiber { }) } - private _resolveConfig(config: any) { - config = this.context.waterfall(this, 'internal/config', config, () => config) - return this.runtime ? resolveConfig(this.runtime, config) : config + /** Raw input retained for the next activation, without evaluating expressions or validating it. */ + get rawConfig(): unknown { + return this.ctx.fiber._config + } + + /** Retain raw input without changing the running config or triggering an update. */ + set rawConfig(config: unknown) { + this.ctx.fiber._config = config + } + + /** + * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. + * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + */ + get effectiveConfig(): unknown { + return this.ctx.fiber.config + } + + /** + * Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. + * Does not publish the result, replace raw input, or commit running volatile references. + * Required injections must be available when a hook uses them. + * @param config Raw candidate input. + * @returns The candidate's resolved config, retaining the schema's result identity. + * @throws {ValidationError} when the runtime schema rejects the candidate; hook errors propagate. + */ + resolveConfig(config: unknown): unknown { + const fiber = this.ctx.fiber + config = fiber.context.waterfall(fiber, 'internal/config', config, () => config) + return fiber.runtime ? resolveConfig(fiber.runtime, config) : config } private async _reload() { @@ -652,7 +679,7 @@ export class Fiber { // the load. Do not run plugin code for a stale epoch; the state update // below will drain any effects collected while the fiber was PENDING. if (this._runner.epoch === oldEpoch) { - this.config = this._resolveConfig(this._config) + this.config = this.resolveConfig(this._config) await this._execute(this._runner) this._error = undefined } @@ -744,7 +771,7 @@ export class Fiber { this._refresh() return } - config = this._resolveConfig(config) + config = this.resolveConfig(config) this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index e7aed96164..39b3461d9b 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -353,7 +353,7 @@ class Hmr extends Service { const reload = (plugin: any, runtime: Plugin.Runtime) => { if (!runtime) return for (const oldFiber of runtime.fibers) { - const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack) + const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.rawConfig, this.getOuterStack) fiber.entry = oldFiber.entry if (fiber.entry) fiber.entry.fiber = fiber } diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 15b566257a..5f819367ee 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,4 +1,4 @@ -import { Context, Fiber, FiberState, Inject, resolveConfig } from '@deepseek-ai/cordis' +import { Context, Fiber, FiberState, Inject } from '@deepseek-ai/cordis' import { deepEqual, isNullable, updateVolatile, volatileEntries, type Volatile } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' @@ -146,7 +146,7 @@ export class Entry { const volatileOnly = changes.length === 1 && changes[0] === 'config' && this.fiber.state === FiberState.ACTIVE && Object.getPrototypeOf(this.ctx) === this.parent.ctx && equalExceptVolatile(legacy.config, this.options.config, this.fiber.runtime?.Config) - if (volatileOnly) this.fiber._config = this.options.config + if (volatileOnly) this.fiber.rawConfig = this.options.config const pending = volatileOnly && this._commitVolatile() ? [] : changes if (!pending.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) @@ -163,18 +163,18 @@ export class Entry { */ private _commitVolatile(): boolean { const fiber = this.fiber! - const refs = volatileEntries(fiber.config) + const refs = volatileEntries(fiber.effectiveConfig) if (!refs.length) return true const raw = this.options.config let candidate: unknown try { - candidate = resolveConfig(fiber.runtime!, fiber.ctx.waterfall(fiber, 'internal/config', raw, () => raw)) + candidate = fiber.resolveConfig(raw) } catch (error) { this.ctx.logger.warn('volatile config update failed for %C', this.options.id) this.ctx.logger.warn(error) return true } - if (!deepEqual(fiber.config, candidate, true)) { + if (!deepEqual(fiber.effectiveConfig, candidate, true)) { this.ctx.logger.debug('ordinary config values of %C changed with its volatile values; applying the ordinary update', this.options.id) return false } From f2f563c681ed85586fe614a2645650a803b3f847 Mon Sep 17 00:00:00 2001 From: Shigma Date: Thu, 15 Oct 2026 16:59:58 +0800 Subject: [PATCH 2/9] fix(cordis): route wrapper updates through the canonical Fiber --- docs/cordis-api/fiber.i18n.yaml | 8 +++--- docs/cordis-api/fiber.md | 6 ++--- docs/cordis-api/fiber.zh.md | 9 +++---- packages/settings/settings/src/index.ts | 1 + scripts/cordis-config-access.spec.ts | 21 +++++++++++++-- scripts/loader-volatile-update.spec.ts | 6 ++--- vendor/README.md | 2 +- vendor/cordis/README.md | 2 +- vendor/cordis/src/fiber.ts | 36 +++++++++++++------------ 9 files changed, 54 insertions(+), 37 deletions(-) diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 6c1d8987ce..042af3ba02 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -24,8 +24,8 @@ en: 1b527d85759324f9 zh: 54aa40db2705b95c /fiber/the-fiber-class/fiber-config: - en: ee804c03c39e7806 - zh: 4353ba7817ec61ec + en: 1efe137a7ef0e599 + zh: bb4b9818fb48f4e9 /fiber/the-fiber-class/fiber-state: en: 999ad29dfc29661b zh: 20245d11c338e3f1 @@ -66,8 +66,8 @@ en: 6ffc1baba98d689f zh: 7e13bc92225c7e0a /fiber/the-fiber-class/fiber-update-config-nosave: - en: 290f7f1bf1207adc - zh: e666cfb53dea897a + en: 5c176a6e67073e92 + zh: ee07edc8dbf7c1f3 /fiber/effect: en: 007d5b214e8a8610 zh: d74c26fbb1b8aeb6 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 366c457d4f..e85cc7b3e6 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -80,11 +80,11 @@ The context this fiber's plugin runs in (extends the parent context). ### fiber.config ```ts cordis-catalog -/** The validated plugin config (updated by `update()`). */ +/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ public config: any ``` -The validated plugin config (updated by `update()`). +The validated plugin config (updated by `update()`); read through effectiveConfig. [Source](../../vendor/cordis/src/fiber.ts#L190) @@ -318,7 +318,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../vendor/cordis/src/fiber.ts#L763) +[Source](../../vendor/cordis/src/fiber.ts#L764) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index 05ee483bdd..d529013e73 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -82,11 +82,11 @@ public readonly ctx: Context ### fiber.config ```ts cordis-catalog -/** The validated plugin config (updated by `update()`). */ +/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ public config: any ``` -经过校验的插件配置(由 `update()` 更新)。 +经过校验的插件配置(由 `update()` 更新);通过 `effectiveConfig` 读取。 [源码](../../vendor/cordis/src/fiber.ts#L190) @@ -218,7 +218,6 @@ getEffects() get rawConfig(): unknown ``` - 保留供下次激活使用的原始输入,不执行表达式求值或验证。 [源码](../../vendor/cordis/src/fiber.ts#L642) @@ -233,7 +232,6 @@ get rawConfig(): unknown get effectiveConfig(): unknown ``` - 既有激活/更新生命周期发布的配置对象,不克隆或重新解析。首次解析前为 undefined,卸载后仍可读取;需要运行实例的调用方应检查 `state`。 [源码](../../vendor/cordis/src/fiber.ts#L655) @@ -252,7 +250,6 @@ get effectiveConfig(): unknown resolveConfig(config: unknown): unknown ``` - 通过此 fiber 的 `internal/config` 钩子和运行时 schema 对候选值求值并验证。此操作不发布结果、不替换原始输入,也不提交运行中的 volatile 引用。钩子访问的注入服务必须已经可用。 - `config`:原始候选输入。 @@ -323,7 +320,7 @@ update(config: any, noSave = false) **返回**无返回值;重启由 `internal/update` waterfall 执行。 -[源码](../../vendor/cordis/src/fiber.ts#L763) +[源码](../../vendor/cordis/src/fiber.ts#L764) ## Effect diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index bd439b2301..9ab841d145 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -317,6 +317,7 @@ export class SettingsForms extends Service { this.ownerContext.emit('settings/document-updated', entry.options.id as SettingsNamespace, revision) } const value = projectForm(form, plainConfig(entry.fiber.effectiveConfig)) + // Inherited layers omit profile fields, so only interpolate and validate them, without the running fiber's config hooks. const resolved: unknown = interpolate(entry.fiber.ctx, inherited) const base = projectForm(form, plainConfig(inheritedConfig(entry.fiber.runtime, resolved))) const user = projectForm(form, override) diff --git a/scripts/cordis-config-access.spec.ts b/scripts/cordis-config-access.spec.ts index 16457afdec..e70d9992f7 100644 --- a/scripts/cordis-config-access.spec.ts +++ b/scripts/cordis-config-access.spec.ts @@ -54,18 +54,35 @@ describe('Fiber configuration access', () => { expect(hook).toHaveBeenCalledOnce() expect(fiber.effectiveConfig).toBe(initial) expect(apply).toHaveBeenCalledOnce() - await fiber.restart() + await wrapper.restart() expect(fiber.effectiveConfig).toBe(raw) expect(apply).toHaveBeenCalledTimes(2) }) + it('publishes updates through registry wrappers to the canonical fiber', async () => { + const ctx = root() + const initial = { value: 1 } + const apply = vi.fn() + const wrapper = ctx.plugin(apply, initial) + await wrapper + const raw = { value: 2 } + wrapper.update(raw) + await wrapper.await() + expect(apply).toHaveBeenLastCalledWith(wrapper.ctx, raw) + expect(wrapper.rawConfig).toBe(raw) + expect(wrapper.effectiveConfig).toBe(raw) + expect(wrapper.ctx.fiber.rawConfig).toBe(raw) + expect(wrapper.ctx.fiber.effectiveConfig).toBe(raw) + expect(apply).toHaveBeenCalledTimes(2) + }) + it('retains a pending raw update until its injection is available', async () => { const ctx = root() ctx.provide('configAccessTest') const apply = vi.fn() const wrapper = ctx.plugin({ inject: ['configAccessTest'], apply }, { value: 1 }) const raw = { value: 2 } - wrapper.ctx.fiber.update(raw) + wrapper.update(raw) expect(wrapper.rawConfig).toBe(raw) expect(wrapper.effectiveConfig).toBeUndefined() ctx.set('configAccessTest', {}) diff --git a/scripts/loader-volatile-update.spec.ts b/scripts/loader-volatile-update.spec.ts index c23486cc66..f2a1c4671a 100644 --- a/scripts/loader-volatile-update.spec.ts +++ b/scripts/loader-volatile-update.spec.ts @@ -481,9 +481,9 @@ it('logs an invalid candidate and keeps its references while other entries commi await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } }) await healthy.entry!.update({ config: { title: 'healthy' } }) expect(warning).toHaveBeenCalledTimes(2) - expect((invalid.config as ReturnType).title.get()).toBe('hello') + expect((invalid.effectiveConfig as ReturnType).title.get()).toBe('hello') expect(invalid.rawConfig).toEqual({ title: 'invalid', nested: { count: -1 } }) - expect((healthy.config as ReturnType).title.get()).toBe('healthy') + expect((healthy.effectiveConfig as ReturnType).title.get()).toBe('healthy') expect(events).toHaveBeenCalledExactlyOnceWith([['title']]) }) @@ -617,7 +617,7 @@ it('isolates a failing notification listener and dispatches with a Context recei expect(receivers).toEqual([fiber]) expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' })) await update(other, { title: 'unaffected' }) - expect((other.config as ReturnType).title.get()).toBe('unaffected') + expect((other.effectiveConfig as ReturnType).title.get()).toBe('unaffected') }) it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => { diff --git a/vendor/README.md b/vendor/README.md index deff0e6500..207cea6847 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,7 +46,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`loader/src/config/entry.ts` activation observer**: the detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit consumer audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. `Fiber.rawConfig`, `Fiber.resolveConfig(candidate)` and `Fiber.effectiveConfig` centralize raw retention, candidate evaluation and reads of the lifecycle-published object. Accessors use the canonical fiber, including writes through registry wrappers; candidate resolution never publishes or commits references. Loader volatile updates, config editing, settings, translator and both HMR providers use these accessors while the existing Core config fields and event protocol remain in place. Covered by `scripts/cordis-config-access.spec.ts`, `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. `Fiber.rawConfig`, `Fiber.resolveConfig(candidate)` and `Fiber.effectiveConfig` centralize raw retention, candidate evaluation and reads of the lifecycle-published object. Accessors, updates and restarts use the canonical fiber, including calls through registry wrappers; candidate resolution never publishes or commits references. Loader volatile updates, config editing, settings, translator and both HMR providers use these accessors while the existing Core config fields and event protocol remain in place. Covered by `scripts/cordis-config-access.spec.ts`, `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`. diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md index 2f7385dcf4..ad3e290a54 100644 --- a/vendor/cordis/README.md +++ b/vendor/cordis/README.md @@ -106,6 +106,6 @@ Schemas may return `Volatile` references, whose values are read through `.get ## Configuration access -`fiber.rawConfig` reads or retains the raw input for a later activation. Assigning it does not validate, update the running instance or trigger a restart. `fiber.resolveConfig(candidate)` evaluates the fiber’s config hooks and schema without publishing the result; any services used by those hooks must already be available. `fiber.effectiveConfig` returns the lifecycle-published object without re-parsing, preserving the identity of volatile references. It is undefined before the first resolution and stays readable after unload, so consumers that require a running instance also check the fiber state. +`fiber.rawConfig` reads or retains the raw input for a later activation. Assigning it does not validate, update the running instance or trigger a restart. Registry wrappers route raw writes, updates and restarts through the same canonical fiber. `fiber.resolveConfig(candidate)` evaluates the fiber's config hooks and schema without publishing the result; any services used by those hooks must already be available. `fiber.effectiveConfig` returns the lifecycle-published object without re-parsing, preserving the identity of volatile references. It is undefined before the first resolution and stays readable after unload, so consumers that require a running instance also check the fiber state. Use `fiber.update()` for an ordinary config change. Loader retains raw input separately when committing only volatile values into the running object. diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index a21f18510d..d4aed3158f 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -186,9 +186,9 @@ export class Fiber { public uid: number | null /** The context this fiber's plugin runs in (extends the parent context). */ public readonly ctx: Context - /** The validated plugin config (updated by `update()`). */ + /** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ public config: any - /** The raw plugin config, re-resolved before each activation. */ + /** The raw plugin config, re-resolved before activation; use {@link rawConfig} to read or retain it. */ public _config: any /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING @@ -743,10 +743,11 @@ export class Fiber { * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. */ async restart() { - this.assertActive() - this._setEpoch(INACTIVE) - this._refresh() - await this.await() + const fiber = this.ctx.fiber + fiber.assertActive() + fiber._setEpoch(INACTIVE) + fiber._refresh() + await fiber.await() } /** @@ -761,21 +762,22 @@ export class Fiber { * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) { - this.assertActive() - this._config = config - if (this.state !== FiberState.ACTIVE) { + const fiber = this.ctx.fiber + fiber.assertActive() + fiber._config = config + if (fiber.state !== FiberState.ACTIVE) { // Config resolution may access injected services, so defer it until the // fiber can activate. - this._error = undefined - this._setEpoch(INACTIVE) - this._refresh() + fiber._error = undefined + fiber._setEpoch(INACTIVE) + fiber._refresh() return } - config = this.resolveConfig(config) - this.context.waterfall(this, 'internal/update', config, noSave, () => { - this.config = config - this._error = undefined - return this.restart() + config = fiber.resolveConfig(config) + fiber.context.waterfall(fiber, 'internal/update', config, noSave, () => { + fiber.config = config + fiber._error = undefined + return fiber.restart() }) } } From 9be99a0e28521436e5ea34ebc6464b3597e519a0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 21 Oct 2026 13:50:19 +0800 Subject: [PATCH 3/9] feat(web): upload composer images when they are added An ordinary Session's composer now uploads each image draft through the background upload queue as soon as it is added. The raw upload route stages the body as a prompt image when the request names its media type: the Host reads at most the per-image byte limit, then validates, normalizes, and stores the image as prompt admission would. The prompt cites the receipt in a file part; prompt admission resolves it to the stored image, applies the image-input check, and counts stored images toward maxImagesPerMessage. Send waits for every image receipt, and a failed image upload turns its thumbnail into a retry control. Commands and subagent continuations keep encoding images inline. --- .../2026-08-26-generic-file-upload.i18n.yaml | 8 +- .../feature/2026-08-26-generic-file-upload.md | 4 +- .../2026-08-26-generic-file-upload.zh.md | 4 +- ...-21-composer-image-upload-on-add.i18n.yaml | 25 +++ ...2026-10-21-composer-image-upload-on-add.md | 35 +++++ ...6-10-21-composer-image-upload-on-add.zh.md | 35 +++++ apps/web/tests/assembled-boot.ts | 3 + apps/web/tests/assembled-remote.ts | 31 +++- apps/web/tests/queue-image.e2e.ts | 3 + docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 12 +- docs/persistence-catalog.md | 6 +- docs/persistence-catalog.zh.md | 6 +- docs/persistence-schema.json | 6 +- docs/subsystems/attachment.md | 31 +++- docs/subsystems/attachment.zh.md | 31 +++- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/commands.ts | 22 +-- packages/api/session-controller/src/types.ts | 3 +- .../tests/commands-upload-file.host.spec.ts | 108 +++++++++++-- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 26 +++- packages/attachment/attachment/src/types.ts | 7 +- .../attachment/tests/admission.spec.ts | 37 +++++ packages/client/file-upload/README.i18n.yaml | 12 +- packages/client/file-upload/README.md | 6 +- packages/client/file-upload/README.zh.md | 6 +- .../client/file-upload/src/client/contract.ts | 23 ++- .../client/file-upload/src/client/runtime.ts | 116 +++++++++++--- packages/client/file-upload/src/http-route.ts | 23 +-- packages/client/file-upload/src/index.ts | 111 +++++++++++--- packages/client/file-upload/src/types.ts | 15 +- .../tests/file-upload-http.host.spec.ts | 23 +++ .../tests/file-upload.client.spec.ts | 49 ++++++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../src/client/ComposerAttachments.module.css | 51 ++++++ .../src/client/ComposerAttachments.tsx | 46 ++++-- .../composer-attachments.client.spec.tsx | 26 ++++ .../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/apply.ts | 4 +- .../src/client/contract/slots.ts | 14 +- .../ui-conversation/src/client/service.ts | 107 +++++++------ .../src/client/skeleton/InputBar.tsx | 11 +- .../tests/input-bar.client.spec.tsx | 25 ++- .../service-orchestration.client.spec.ts | 145 +++++++++++++++--- .../src/client/slot-catalog.ts | 56 +++---- .../extensions/tool-cordis/src/api-catalog.ts | 26 +++- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../test-support/client-runtime/src/index.ts | 3 + .../tests/helpers.client.spec.tsx | 1 + scripts/gen-cordis-catalog.ts | 2 + 62 files changed, 1124 insertions(+), 280 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md create mode 100644 .agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 496d2e8136..98789701f0 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -9,8 +9,8 @@ en: e7c3b5d7702714fe zh: 268f162b553b37d1 /agent-note-generic-file-upload/decision: - en: 31c7ad96c6ab82c8 - zh: 806922fc8fcd1242 + en: 1769950d73d30dca + zh: bf296b920ea9b997 /agent-note-generic-file-upload/alternatives-considered: en: 23f386b05e4e79ef zh: 0756c4fa30e8d0eb @@ -21,5 +21,5 @@ en: 3a50ac370d51cda9 zh: d490f58922f8795b /agent-note-generic-file-upload/related: - en: 7579c49eefdb8488 - zh: 68ad384fd9d2f8f9 + en: 14e8659b89dc08a8 + zh: dec5baa11f168e91 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index f60649a083..133312a83c 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. -Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. ## Alternatives considered @@ -54,3 +54,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat The [Trajectory reference](../../../../packages/client/ui-trajectory/README.md) documents ledger counts and inspector attachment layouts. The [input echo admission reference](../../../../packages/api/session-controller/README.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments. + +The [composer image upload on add](2026-10-21-composer-image-upload-on-add.md) note extends this background-upload queue to the images of an ordinary Session's draft. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 3d1667d181..dfc48509c5 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -14,7 +14,7 @@ Status: implemented 文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 -默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每项上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 ## 考虑过的替代方案 @@ -54,3 +54,5 @@ Status: implemented [Trajectory 参考](../../../../packages/client/ui-trajectory/README.zh.md)说明列表计数与详情面板附件布局。 [输入回显入档参考](../../../../packages/api/session-controller/README.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。 + +[输入框在加入时上传图片](2026-10-21-composer-image-upload-on-add.zh.md) 记录把这条后台上传队列扩展到普通 Session 草稿里的图片。 diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml new file mode 100644 index 0000000000..c6eb3173f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-21-composer-image-upload-on-add.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md +/agent-note-composer-image-upload-on-add: + en: 996b39b53086b9ac + zh: c81fade50ac895ec +/agent-note-composer-image-upload-on-add/problem: + en: b184ade2bd353f50 + zh: 00b97c91df54b310 +/agent-note-composer-image-upload-on-add/decision: + en: 798706ad746c8eff + zh: 64ebebfe57abbcad +/agent-note-composer-image-upload-on-add/alternatives-considered: + en: afa82b9bce9015f6 + zh: d088b92c2cb6c622 +/agent-note-composer-image-upload-on-add/consequences: + en: 658074ab09bcdf8f + zh: 2d32fff0de0144b6 +/agent-note-composer-image-upload-on-add/testing: + en: 6ca1a9520019b074 + zh: 68a2ed83e999116f +/agent-note-composer-image-upload-on-add/related: + en: 6eac98f60c3b99f9 + zh: c1e318e74f44fbb4 diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md new file mode 100644 index 0000000000..a96b6f3385 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md @@ -0,0 +1,35 @@ +# Agent Note: Composer image upload on add + +Status: implemented + +English | [中文](2026-10-21-composer-image-upload-on-add.zh.md) + +## Problem + +The Web composer kept image drafts in the browser and base64-encoded each image into `session/prompt` at send time, so the Host received, validated, normalized, and stored image bytes inside prompt admission. Generic files already uploaded when picked ([generic file upload](2026-08-26-generic-file-upload.md)). An image message therefore carried up to the per-message image byte limit in one RPC body, and its send time grew with image size; a composer that waits for the Host to accept a message before clearing it would hold the draft for that whole transfer. + +## Decision + +An ordinary Session's composer uploads each image draft as soon as it is added, through the same FIFO background-upload queue, Worker carriers, progress, retry, and cancellation that generic files use. The raw upload route takes a `mediaType` query parameter for an image upload; the Host reads at most the deployment's per-image byte limit, then validates, normalizes, and stores the image with `ctx.attachments.saveImages` exactly as prompt admission would, and stages the normalized image under a receipt for that Session. The prompt cites the receipt in an ordinary file part, so `session/prompt` keeps its wire types; prompt admission resolves the receipt to the stored image, applies the model's image-input check to the prompt, and counts stored images toward `maxImagesPerMessage` together with encoded ones. Send waits until every image draft has a receipt, and a failed image upload turns its thumbnail into a retry control. Commands accept only file receipts, so command submissions keep encoding images inline. A continuable subagent's composer keeps inline images because the Host stages uploads only for ordinary Sessions; SDK and other wire callers may still send encoded images. + +## Alternatives considered + +- **A dedicated image-receipt prompt part.** Rejected: the receipt already names what was staged, so a second part kind would widen the `session/prompt` wire union, its generated catalogs, and every consumer that switches on parts without changing what the Host admits. +- **A separate image upload route or Remote method.** Rejected: one route with an explicit `mediaType` parameter reuses Session addressing, carrier selection, progress, and the failure envelope; the exact-bytes Remote fallback has no image caller. +- **Upload images but normalize them at send.** Rejected: normalization is the expensive part of image admission, so deferring it would keep the send-time cost this change removes. + +## Consequences + +- Sending an image message transfers only references, and the normalization cost moves to the moment the image is added. +- Per-image refusals such as an unsupported type or an oversized image surface when the image is added; the model's image-input check and the per-message image count still apply at send. +- An image added and then removed before sending is still stored under `DSH_HOME`, which never deletes attachments; on a remote Host the bytes reach the server as soon as the user adds the image. +- Staged image receipts share the generic files' in-memory lifetime: a Host restart between adding and sending surfaces `FILE_NOT_STAGED`, and the user adds the image again. +- Image drafts, like file drafts, are not restored after a page reload or after their Session's client scope is released. + +## Testing + +Host coverage pins image staging through the raw route, the per-image byte bound that stops reading, unsupported types, subagent refusal, command refusal of image receipts, receipt resolution into the admitted image, and the image-input check for an uploaded image. Attachment admission coverage pins stored-image pass-through and the combined image count. Client coverage pins image upload start, retry, cancellation, the send gate, receipt citation without re-encoding, inline images for drafts without an upload, and the rail's progress and retry states. + +## Related + +The [generic file upload](2026-08-26-generic-file-upload.md) note owns the background-upload queue, carriers, staging lifetime, and file storage that image uploads reuse. diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md new file mode 100644 index 0000000000..50f3e18a0a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 输入框在加入时上传图片 + +Status: implemented + +[English](2026-10-21-composer-image-upload-on-add.md) | 中文 + +## 问题 + +Web 输入框把图片草稿留在浏览器里,发送时才把每张图片 base64 编码进 `session/prompt`,Host 在 prompt 准入时才接收、校验、规整并存储图片字节。通用文件早已在选中时上传([通用文件上传](2026-08-26-generic-file-upload.zh.md))。因此一条图片消息要在一个 RPC 请求体里携带最多达到单条消息图片字节上限的数据,发送耗时随图片大小增长;如果输入框要等 Host 接纳消息后才清空,草稿就要在整个传输期间一直保留。 + +## 决定 + +普通 Session 的输入框在图片草稿加入时就上传它,复用通用文件的先进先出后台上传队列、Worker 载体、进度、重试与取消。原始上传路由用 `mediaType` 查询参数表示图片上传;Host 最多读取部署配置的单张图片字节上限,然后按 prompt 准入的同样方式用 `ctx.attachments.saveImages` 校验、规整并存储图片,再以该 Session 的凭证暂存规整后的图片。prompt 在普通文件部分引用该凭证,因此 `session/prompt` 的线上类型不变;prompt 准入把凭证解析为已存储的图片,对这条 prompt 执行模型的图片输入检查,并把已存储图片与编码图片一起计入 `maxImagesPerMessage`。发送会等到每个图片草稿都取得凭证;图片上传失败时,缩略图变成重试控件。命令只接受文件凭证,所以命令提交仍以内联方式编码图片。continuable 子代理的输入框继续内联图片,因为 Host 只为普通 Session 暂存上传;SDK 和其他线上调用方仍可发送编码图片。 + +## 考虑过的替代方案 + +- **专用的图片凭证 prompt 部分。** 已否决:凭证本身已经指明暂存的内容,新增一种部分类型会扩大 `session/prompt` 线上联合类型、它的生成目录以及每个按部分类型分支的消费方,却不改变 Host 准入的内容。 +- **单独的图片上传路由或 Remote 方法。** 已否决:一条带显式 `mediaType` 参数的路由复用了 Session 寻址、载体选择、进度和失败信封;精确字节的 Remote 兜底没有图片调用方。 +- **加入时上传、发送时再规整。** 已否决:规整是图片准入中开销最大的部分,推迟它会保留这次改动要消除的发送耗时。 + +## 后果 + +- 发送图片消息只传引用,规整开销移到图片加入的时刻。 +- 不支持的类型、超过单张上限等逐张拒绝在图片加入时出现;模型的图片输入检查和单条消息图片数量仍在发送时执行。 +- 加入后在发送前又移除的图片仍会存储在 `DSH_HOME` 下,附件存储从不删除;Host 在远端时,用户一加入图片,字节就到达服务器。 +- 暂存的图片凭证与通用文件同样只存在内存里:在加入与发送之间重启 Host 会得到 `FILE_NOT_STAGED`,用户需要重新加入图片。 +- 与文件草稿一样,图片草稿在页面刷新后或其 Session 的客户端作用域释放后不会恢复。 + +## 测试 + +Host 侧覆盖经原始路由暂存图片、单张字节上限一超出即停止读取、不支持的类型、子代理拒绝、命令拒绝图片凭证、凭证解析为准入后的图片,以及已上传图片的图片输入检查。附件准入覆盖已存储图片原样通过与合并后的图片数量。客户端覆盖图片上传的开始、重试、取消、发送闸门、发送时引用凭证而不重新编码、没有上传的草稿以内联方式发送图片,以及附件栏的进度与重试状态。 + +## 相关记录 + +[通用文件上传](2026-08-26-generic-file-upload.zh.md) 记录负责图片上传所复用的后台上传队列、载体、暂存生命周期与文件存储。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index e58ee67be0..42f006222d 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -179,6 +179,7 @@ interface FixtureWindow extends Window { __DSH_BOOT__?: WebBootGraph __ModuleLoader__?: ClientModuleLoaderTarget __DSH_TRANSPORT__?: { readonly rpc: RemoteMock['rpc'] } + __DSH_FILE_UPLOAD__?: { readonly fetch: AssembledRemote['fileUpload'] } } class ResizeObserverStub { @@ -253,6 +254,7 @@ export function installAssembledBootEnv(): void { delete win.__DSH_BOOT__ delete win.__ModuleLoader__ delete win.__DSH_TRANSPORT__ + delete win.__DSH_FILE_UPLOAD__ document.body.innerHTML = '' document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' @@ -281,6 +283,7 @@ export function mountAssembledApp(options: AssembledBootOptions = {}): Assembled const remote = createAssembledRemote(options.remote) mountedRemote = remote.mock win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc } + win.__DSH_FILE_UPLOAD__ = { fetch: remote.fileUpload } history.replaceState(null, '', '/') const root = document.createElement('div') root.id = 'root' diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index be74dc87b6..401508bc2c 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -5,6 +5,7 @@ * Fixture Sessions have no retained Host terminals to restore. */ +import { Buffer } from 'node:buffer' import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -106,6 +107,8 @@ export interface AssembledRemoteOptions { export interface AssembledRemote { readonly mock: RemoteMock + /** Page-owned upload carrier: stages an image upload as the Host would and answers its receipt. */ + readonly fileUpload: (input: string | URL, init: RequestInit) => Promise } const fixtureSource = readFileSync( @@ -132,6 +135,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass return [sessionId, next] as const })) const attachments = new Map([['fixture:image', structuredClone(fixture.attachment)]]) + const stagedImages = new Map>() const blankSessionProjections = fixture.control.value.projections['fx-gamma'] if (blankSessionProjections === undefined) { throw new Error('assembled fixture: blank Session projections missing') @@ -312,6 +316,10 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass const summary = sessions.find(candidate => candidate.sessionId === sessionId) if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`) const content = recordArray(request, 'content').map((part) => { + if (isRecord(part) && part.type === 'file') { + const image = stagedImages.get(recordString(part, 'receiptId')) + return image === undefined ? part : { type: 'image', attachment: image } + } if (!isRecord(part) || part.type !== 'image') return part const attachmentId = `assembled:${randomUUID()}` const data = recordString(part, 'data') @@ -364,7 +372,28 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass }) }) - return { mock } + const fileUpload = async (input: string | URL, init: RequestInit): Promise => { + const url = new URL(String(input), 'http://assembled.test/') + const mediaType = url.searchParams.get('mediaType') + if (!url.pathname.endsWith('/api/session/uploadFileBinary') || mediaType === null || !(init.body instanceof Blob)) { + throw new Error(`assembled fixture: unexpected upload ${url.pathname}${url.search}`) + } + const bytes = new Uint8Array(await init.body.arrayBuffer()) + const name = url.searchParams.get('name') + const image = { + attachmentId: `assembled:${randomUUID()}`, + mediaType, + bytes: bytes.byteLength, + width: 160, + height: 90, + ...name === null ? {} : { name }, + } + attachments.set(image.attachmentId, ok({ attachment: image, data: Buffer.from(bytes).toString('base64') })) + const receiptId = `assembled-receipt:${randomUUID()}` + stagedImages.set(receiptId, image) + return new Response(JSON.stringify({ ok: true, value: { receiptId, image } }), { status: 200 }) + } + return { mock, fileUpload } } function eventOf( diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index 502bb0d7c9..fa6fe753a0 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -100,6 +100,9 @@ describe('web e2e: queued image submission', () => { 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 }) + // The image uploads as soon as it is added, and Send waits for its receipt. + await page.locator('button[aria-busy="false"]', { has: page.getByRole('img', { name: 'queued.png' }) }) + .waitFor({ timeout: 10_000 }) const releasePrompt = Promise.withResolvers() const releaseImage = Promise.withResolvers() let cleanupPromise: Promise | undefined diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 453e8687b9..3f7e3a9c7f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:634`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:613`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9576e87449..209ef9de2c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:634`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:613`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ed3dfb8f6c..6726560dfe 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1146,8 +1146,8 @@ en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: bf5990688bae50ac - zh: 9212bca7aa7d31e0 + en: a756bce4b580d325 + zh: 84ab7988df86487d /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1347,8 +1347,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 41c03b03d1bd5a44 - zh: 1b0e430af6c140b3 + en: 10d206dcc422b065 + zh: 8e65d17727283dcd /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1833,8 +1833,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 53b7abac679ee252 - zh: 63eb6a015986ef6d + en: ecd4a562cf440158 + zh: 9bd3ec23c094a14e /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index da02534d25..bf13b09647 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -4337,7 +4337,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:98`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:99`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5583,7 +5583,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:407`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:416`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 062a1e58b1..25831e4740 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -4339,7 +4339,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:98`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:99`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:407`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:416`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 4d0ab002a4..8c4c432191 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -253,7 +253,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:194"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:406","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:407","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]}, @@ -335,7 +335,7 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:415"]}, + {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:416"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, @@ -640,7 +640,7 @@ {"digest":"ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21","names":[],"sources":[]}, {"digest":"ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45","names":[],"sources":[]}, {"digest":"f05fb85e715af4ab4de0b6ba111166dd0435bd8bb74a0fa0f32b9d174113fc7c","names":[],"sources":[]}, - {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/api/session-controller/src/types.ts#ModelSelection"],"sources":["packages/api/session-controller/src/types.ts:98"]}, + {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/api/session-controller/src/types.ts#ModelSelection"],"sources":["packages/api/session-controller/src/types.ts:99"]}, {"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:233"]}, {"digest":"f1996af2602e6f62b5ab179ca5c4af00d8753938b74cc6ef102de09e99e74a7e","names":[],"sources":[]}, {"digest":"f19bee84bf3d20904553cdcd57465232bc1c2a194f54c4d195292ba0487223a6","names":["packages/attachment/attachment/src/types.ts#FileAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:39"]}, diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 12ae675e3c..0adca9cef8 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -80,10 +80,15 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +/** + * Host prompt content whose upload receipts are resolved. An image part + * carries encoded bytes awaiting admission, or references an image this + * service already stored for an upload the same Session staged. + */ type AttachmentAdmissionPart = | PromptContentPart | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } ``` ```ts type-equiv @@ -200,9 +205,11 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one Host prompt and replace each uploaded image with its durable reference. - * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. - * @param content - prompt parts in message order after file receipt resolution. + * Admit one Host prompt and replace each encoded image with its durable reference. + * Text, durable file references, and already stored images pass through unchanged. + * Every image part counts toward `maxImagesPerMessage`; the aggregate byte limit + * applies to the encoded images. A prompt without encoded images performs no storage operation. + * @param content - prompt parts in message order after upload receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ @@ -320,7 +327,7 @@ registerAgentResolver(resolve: AgentResolver): () => void * @param signal - caller cancellation before storage begins. * @returns the staged receipt and durable file reference. */ -@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise +@Remote('upload') async upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise /** * Persist raw chunks for one Session without aggregating the upload. @@ -329,13 +336,23 @@ registerAgentResolver(resolve: AgentResolver): () => void */ async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise +/** + * Persist one prompt image for a Session: read at most the deployment's + * per-image byte limit, then validate, normalize, and store the image the + * way prompt admission does, so a later prompt cites it by receipt. + * @param request - Session identity, ordered bytes, the declared media type that this deployment + * must accept, cancellation, and optional display name. + * @returns the staged receipt and durable normalized image reference. + */ +async uploadImageStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly mediaType: string readonly signal?: AbortSignal readonly name?: string }): Promise + /** * Resolve one staged receipt for the receiving Agent's exact Session. * @param agent - receiving Agent. * @param receiptId - opaque receipt minted for one completed upload. - * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + * @returns the staged file or image, or `undefined` for an unknown or foreign receipt. */ -resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined +resolve(agent: Agent, receiptId: FileUploadReceiptId): StagedAttachment | undefined /** * Bind receipts while one prompt enters an Agent inbox. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 06ea726f19..c7400d385f 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -80,10 +80,15 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +/** + * Host prompt content whose upload receipts are resolved. An image part + * carries encoded bytes awaiting admission, or references an image this + * service already stored for an upload the same Session staged. + */ type AttachmentAdmissionPart = | PromptContentPart | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } ``` ```ts type-equiv @@ -200,9 +205,11 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one Host prompt and replace each uploaded image with its durable reference. - * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. - * @param content - prompt parts in message order after file receipt resolution. + * Admit one Host prompt and replace each encoded image with its durable reference. + * Text, durable file references, and already stored images pass through unchanged. + * Every image part counts toward `maxImagesPerMessage`; the aggregate byte limit + * applies to the encoded images. A prompt without encoded images performs no storage operation. + * @param content - prompt parts in message order after upload receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ @@ -320,7 +327,7 @@ registerAgentResolver(resolve: AgentResolver): () => void * @param signal - caller cancellation before storage begins. * @returns the staged receipt and durable file reference. */ -@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise +@Remote('upload') async upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise /** * Persist raw chunks for one Session without aggregating the upload. @@ -329,13 +336,23 @@ registerAgentResolver(resolve: AgentResolver): () => void */ async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise +/** + * Persist one prompt image for a Session: read at most the deployment's + * per-image byte limit, then validate, normalize, and store the image the + * way prompt admission does, so a later prompt cites it by receipt. + * @param request - Session identity, ordered bytes, the declared media type that this deployment + * must accept, cancellation, and optional display name. + * @returns the staged receipt and durable normalized image reference. + */ +async uploadImageStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly mediaType: string readonly signal?: AbortSignal readonly name?: string }): Promise + /** * Resolve one staged receipt for the receiving Agent's exact Session. * @param agent - receiving Agent. * @param receiptId - opaque receipt minted for one completed upload. - * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + * @returns the staged file or image, or `undefined` for an unknown or foreign receipt. */ -resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined +resolve(agent: Agent, receiptId: FileUploadReceiptId): StagedAttachment | undefined /** * Bind receipts while one prompt enters an Agent inbox. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index ff8ee89c2f..6e0dfbef8f 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 8fcfa14545bd9d1c - zh: c10c2bba792699ef + en: aabd5be911e477f5 + zh: 2f7fdb44bc8777bc /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fbc0aeaa3b..03fddab2a9 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index d63735689f..1f33f9ec4e 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 8dd38573c8..74455d9057 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -7,9 +7,9 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { - AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, + AttachmentAdmissionPart, ImageAttachmentRef, } from '@deepseek-ai/dsh-attachment' -import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import type { FileUploadReceiptId, StagedAttachment } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage, @@ -319,7 +319,12 @@ export class SessionCommandController { async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } - const hasImage = request.content.some(part => part.type === 'image') + const admission = resolvePromptFileReceipts( + request.content, + receiptId => this.ctx.fileUploads.resolve(agent, receiptId), + ) + // Encoded images and image upload receipts both make an image prompt. + const hasImage = admission.content.some(part => part.type === 'image') const admit = async (): Promise => { try { if (hasImage) { @@ -333,10 +338,6 @@ export class SessionCommandController { ) } } - const admission = resolvePromptFileReceipts( - request.content, - receiptId => this.ctx.fileUploads.resolve(agent, receiptId), - ) const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) if (this.ctx.agents.get(agent.id) !== agent) { @@ -560,14 +561,15 @@ export class SessionCommandController { } } +/** Replace each file part with the file or image its staged receipt names. */ function resolvePromptFileReceipts( content: SessionPromptRequest['content'], - stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, + staged: (receiptId: FileUploadReceiptId) => StagedAttachment | undefined, ): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } { const receiptIds = new Set() const resolved = content.map((part): AttachmentAdmissionPart => { if (part.type !== 'file') return part - const attachment = stagedFile(part.receiptId) + const attachment = staged(part.receiptId) if (attachment === undefined) { throw new RemoteError( 'session/attachment-invalid', @@ -576,7 +578,7 @@ function resolvePromptFileReceipts( ) } receiptIds.add(part.receiptId) - return { type: 'file', attachment } + return attachment }) return { content: resolved, receiptIds: [...receiptIds] } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index f4d2daa637..7b1099754a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -82,7 +82,8 @@ export type SessionProjectionValues = Partial /** * Browser-submitted prompt content; the Host promotes image bytes to durable * references. File parts carry the opaque receipt returned by a preceding - * `uploadFile` call on the same Session. + * upload on the same Session; the receipt of an image upload enters the + * message as that image. */ export type PromptContentPart = | { readonly type: 'text'; readonly text: string } diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 48ba896f01..6ad3a71b97 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -5,7 +5,8 @@ import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { - FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, + SaveImageAttachment, } from '@deepseek-ai/dsh-attachment' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm' @@ -20,6 +21,26 @@ import { SessionCommandController } from '../src/commands.ts' import type { SessionRequestId } from '../src/types.ts' const SESSION = SessionId('upload-session') +const IMAGE_LIMITS: ImageAttachmentLimits = { + maxImageBytes: 4, + maxImagesPerMessage: 2, + maxMessageImageBytes: 8, + maxImagePixels: 64, + maxImageDimension: 8, + mediaTypes: ['image/png'], +} +const STAGED_IMAGE: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'12'.repeat(32)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + name: 'shot.png', +} + +async function* chunks(...parts: readonly number[][]): AsyncIterable { + for (const part of parts) yield Uint8Array.from(part) +} async function uploadHarness(origin?: 'subagent'): Promise<{ ctx: Context @@ -30,6 +51,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ saveFile: ReturnType saveFileStream: ReturnType saveImages: ReturnType + resolveModelInfo: ReturnType disposeAgent: () => Promise uploadRoute: (request: Request) => Promise }> { @@ -70,10 +92,10 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ bytes, } }) - const saveImages = vi.fn((): Promise => + const saveImages = vi.fn((_inputs: readonly SaveImageAttachment[]): Promise => Promise.reject(new Error('fixture did not expect image persistence'))) ctx.provide('attachments', Object.setPrototypeOf( - { saveFile, saveFileStream, saveImages }, + { saveFile, saveFileStream, saveImages, imageLimits: IMAGE_LIMITS }, AttachmentStore.prototype, ) as never) let uploadRoute: ((request: Request) => Promise) | undefined @@ -85,10 +107,16 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }, }, } as never) + const resolveModelInfo = vi.fn((): Promise<{ + readonly provider: string + readonly id: string + readonly name: string + readonly inputModalities?: readonly string[] + }> => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' })) ctx.provide('llm', { listModels: async () => [{ id: 'fixture-model', name: 'Model' }], listProviders: () => [{ id: 'fixture', name: 'Fixture' }], - resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), + resolveModelInfo, } as never) const selection: ModelSelectionRef = { current: { provider: 'fixture', model: 'fixture-model' }, @@ -110,6 +138,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ saveFile, saveFileStream, saveImages, + resolveModelInfo, disposeAgent, uploadRoute, } @@ -140,7 +169,7 @@ describe('Session file uploads', () => { expect(saveFile).toHaveBeenCalledTimes(1) expect(receipt.file.name).toBe('notes.pdf') expect(receipt.file.bytes).toBe(3) - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined() const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) ctx.commands.register({ @@ -201,6 +230,62 @@ describe('Session file uploads', () => { ]) }) + it('stages an uploaded image and admits its receipt as that image, which commands do not accept', async () => { + const { ctx, controller, uploads, agent, followup, saveImages } = await uploadHarness() + saveImages.mockResolvedValueOnce([STAGED_IMAGE]) + const receipt = await uploads.uploadImageStream({ + sessionId: SESSION, data: chunks([1, 2], [3, 4]), mediaType: 'image/png', name: 'shot.png', + }) + expect(saveImages).toHaveBeenCalledWith([{ data: Uint8Array.of(1, 2, 3, 4), mediaType: 'image/png', name: 'shot.png' }]) + expect(receipt.image).toEqual(STAGED_IMAGE) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'image', attachment: STAGED_IMAGE }) + const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'files', description: 'Use staged files', input: { hint: '', attachments: true }, + handler: commandHandler, + }) + await ctx.commands.execute( + agent, + '/files inspect', + [{ type: 'file', receiptId: receipt.receiptId }], + new AbortController().signal, + ) + expect(commandHandler).not.toHaveBeenCalled() + await controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'text', text: 'look' }, + ]), USER_SOURCE) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { type: 'image', attachment: STAGED_IMAGE }, + { type: 'text', text: 'look' }, + ]) + expect(saveImages).toHaveBeenCalledTimes(1) + }) + + it('refuses an uploaded image for a model without image input', async () => { + const { controller, uploads, followup, saveImages, resolveModelInfo } = await uploadHarness() + saveImages.mockResolvedValueOnce([STAGED_IMAGE]) + const receipt = await uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1]), mediaType: 'image/png' }) + resolveModelInfo.mockResolvedValue({ provider: 'fixture', id: 'fixture-model', name: 'Fixture', inputModalities: ['text'] }) + await expect(controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }) + expect(followup).not.toHaveBeenCalled() + }) + + it('refuses an image past the per-image byte limit without reading further, and an unaccepted type', async () => { + const { uploads, saveImages } = await uploadHarness() + const overLimit = (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2, 3) + yield Uint8Array.of(4, 5) + throw new Error('read past the limit') + })() + await expect(uploads.uploadImageStream({ sessionId: SESSION, data: overLimit, mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'IMAGE_TOO_LARGE' } }) + await expect(uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1]), mediaType: 'image/bmp' })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'UNSUPPORTED_IMAGE_TYPE' } }) + expect(saveImages).not.toHaveBeenCalled() + }) + it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => { const { uploads, saveFileStream } = await uploadHarness() const stream = (async function* (): AsyncIterable {})() @@ -296,6 +381,9 @@ describe('Session file uploads', () => { details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, }) expect(child.saveFile).not.toHaveBeenCalled() + await expect(child.uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1]), mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } }) + expect(child.saveImages).not.toHaveBeenCalled() }) it('isolates receipts by Session object even when Session ids match', async () => { @@ -311,7 +399,7 @@ describe('Session file uploads', () => { expect(() => owner.uploads.bindPrompt(other.agent, [receipt.receiptId], 'other-prompt')) .toThrow(expect.objectContaining({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })) owner.uploads.retirePrompt(other.agent, 'owner-prompt') - expect(owner.uploads.resolve(owner.agent, receipt.receiptId)).toEqual(receipt.file) + expect(owner.uploads.resolve(owner.agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) owner.uploads.retirePrompt(owner.agent, 'owner-prompt') expect(owner.uploads.resolve(owner.agent, receipt.receiptId)).toBeUndefined() }) @@ -321,9 +409,9 @@ describe('Session file uploads', () => { uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) uploads.retirePrompt(agent, 'other-request') - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -340,7 +428,7 @@ describe('Session file uploads', () => { content: [{ type: 'text', text: 'extension event' }], source: { kind: 'user', rpcId: 1 } as never, }), { surfaceOp: 'append' }) - expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual({ type: 'file', attachment: observed.file }) first.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'observed' }], source: { kind: 'user', rpcId: 'req-1' as SessionRequestId }, @@ -442,7 +530,7 @@ describe('Session file uploads', () => { { type: 'file', receiptId: receipt.receiptId }, ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) }) it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 4f941409fe..eb1d6b6f1d 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -39,8 +39,8 @@ en: af7deef72fd2fa93 zh: f217d52ce3971135 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: - en: 4a35cdba790c3b8b - zh: 08d4a18bbad4b182 + en: b3d6af4425252120 + zh: ed670ebded2d20b3 /deepseek-ai-dsh-attachment/understand-the-implementation/source-map: en: 4fb4c96184c38169 zh: 015b06e4bfff4fcd diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 43eb4d8ae8..9aa7eb52da 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,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. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes 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, streamed writes, 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. Host prompt consumers pass ordered text, encoded images, and already resolved file and image references to `ctx.attachments.admitPromptContent()`; the method persists the encoded images, passes references unchanged, and counts every image toward the per-message image count. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes 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, streamed writes, 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 diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 4fa95d20f3..cdb3abf090 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件与图片引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化编码图片,让引用原样通过,并把每张图片计入单条消息的图片数量。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index d5854336db..45edd7589e 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -11,6 +11,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, ImageRequestTarget, + PromptContentPart, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -49,6 +50,11 @@ declare module '@deepseek-ai/cordis' { } } +/** Whether one admission part still carries encoded image bytes. */ +function isEncodedImage(part: AttachmentAdmissionPart): part is Extract { + return part.type === 'image' && !('attachment' in part) +} + /** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */ export abstract class AttachmentStore extends Service { constructor(ctx: Context) { @@ -105,25 +111,29 @@ export abstract class AttachmentStore extends Service { } /** - * Admit one Host prompt and replace each uploaded image with its durable reference. - * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. - * @param content - prompt parts in message order after file receipt resolution. + * Admit one Host prompt and replace each encoded image with its durable reference. + * Text, durable file references, and already stored images pass through unchanged. + * Every image part counts toward `maxImagesPerMessage`; the aggregate byte limit + * applies to the encoded images. A prompt without encoded images performs no storage operation. + * @param content - prompt parts in message order after upload receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise { - if (content.every(part => part.type !== 'image')) { - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - : { type: 'file', attachment: part.attachment }) + const encoded = content.filter(isEncodedImage) + const images = content.filter(part => part.type === 'image').length + // The encoded batch enforces its own count; stored images join it here. + if (images > encoded.length && images > this.imageLimits.maxImagesPerMessage) { + throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') } - const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) + const refs = encoded.length === 0 ? [] : await admitEncodedImages(this, encoded) let next = 0 return content.map((part) => { if (part.type === 'text') return { type: 'text', text: part.text } if (part.type === 'file') return { type: 'file', attachment: part.attachment } + if ('attachment' in part) return { type: 'image', attachment: part.attachment } return { type: 'image', attachment: refs[next++] as ImageAttachmentRef } }) } diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 455d6c9de0..c0ff2bceea 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -106,10 +106,15 @@ export type PromptContentPart = readonly name?: string } -/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +/** + * Host prompt content whose upload receipts are resolved. An image part + * carries encoded bytes awaiting admission, or references an image this + * service already stored for an upload the same Session staged. + */ export type AttachmentAdmissionPart = | PromptContentPart | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } /** Host-admitted prompt content with every attachment represented by its durable reference. */ export type AdmittedPromptContentPart = diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 71c56c0fed..7da98842d0 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -10,6 +10,13 @@ const FILE_REF: FileAttachmentRef = { name: 'notes.md', bytes: 12, } +const STORED_REF: ImageAttachmentRef = { + attachmentId: 'stored-1' as ImageAttachmentRef['attachmentId'], + mediaType: 'image/png', + bytes: 8, + width: 2, + height: 2, +} /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { @@ -138,4 +145,34 @@ describe('AttachmentStore.admitPromptContent', () => { { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) }) + + it('passes stored images through in part order and stores only the encoded ones', async () => { + const { store, mocks } = storeOf() + Object.defineProperty(store, 'imageLimits', { value: { maxImagesPerMessage: 2 } }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF }, + { type: 'text', text: 'between' }, + { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + ])).resolves.toEqual([ + { type: 'image', attachment: STORED_REF }, + { type: 'text', text: 'between' }, + { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ]) + expect(mocks.saveImages).toHaveBeenCalledTimes(1) + expect((mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[]).length).toBe(1) + }) + + it('counts stored images toward the image-count limit before storing anything', async () => { + const { store, mocks } = storeOf() + Object.defineProperty(store, 'imageLimits', { value: { maxImagesPerMessage: 1 } }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF }, + { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + ])).rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF }, + { type: 'image', attachment: { ...STORED_REF, attachmentId: 'stored-2' as ImageAttachmentRef['attachmentId'] } }, + ])).rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' }) + expect(mocks.saveImages).not.toHaveBeenCalled() + }) }) diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index b8f993bd2d..287865e683 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -9,17 +9,17 @@ en: a6c9f156174554e1 zh: cd1a126905ad859b /deepseek-ai-dsh-client-file-upload/summary: - en: 430b79a11643da11 - zh: 5fbab8d425b7f15a + en: e53f2cfb8285a578 + zh: 1930ea2638e59a34 /deepseek-ai-dsh-client-file-upload/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-file-upload/use-this-package: - en: 9d5bbf52599b0d00 - zh: 7907542ce1e34549 + en: 7c19ce780c0d1357 + zh: 2d295b8b872b5f9f /deepseek-ai-dsh-client-file-upload/understand-the-implementation: - en: 77937960a540cf51 - zh: 52c478bd11200975 + en: cbfa8996ccad9e41 + zh: 0a0395d1194a1423 /deepseek-ai-dsh-client-file-upload/further-exploration: en: ac94eb9da9ecc296 zh: 25cc3362e2bea330 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index b447226218..b2ece40ab7 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. Exact bytes use the generated Remote. +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. An image upload stages a normalized prompt image that a later prompt cites by receipt. ## Table of Contents @@ -25,7 +25,7 @@ This package lets browser features store a `Blob`, exact bytes, or a `ReadableSt ## Use this package -Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request. +Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`, or `ctx.fileUpload.uploadImage(sessionId, blob, mediaType, name, signal, onProgress)` for a prompt image. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request. ```yaml - id: file-upload @@ -44,7 +44,7 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote for exact bytes. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each Blob or stream request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. -The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Host callers supply an Agent with its runtime-owned context; receipt tables use that Agent's exact Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. A raw upload with a `mediaType` query parameter reads at most the deployment's per-image byte limit and stores the image through `ctx.attachments.saveImages`; its receipt resolves to that image during prompt admission, and commands accept only file receipts. Host callers supply an Agent with its runtime-owned context; receipt tables use that Agent's exact Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. | File | Role | |---|---| diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index 86bc9ef6f5..56a2e46aff 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。精确字节使用生成的 Remote。 +本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。图片上传会暂存一张规整后的 prompt 图片,供后续 prompt 凭证引用。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。 +在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`;prompt 图片则调用 `ctx.fileUpload.uploadImage(sessionId, blob, mediaType, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。 ```yaml - id: file-upload @@ -44,7 +44,7 @@ kind: "package-reference" Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为精确字节调用生成的 Remote。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个 Blob 或 stream 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 -Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。Host 调用方传入具有运行时所属上下文的 Agent;凭证表以该 Agent 的准确 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。带 `mediaType` 查询参数的原始上传最多读取部署配置的单张图片字节上限,并通过 `ctx.attachments.saveImages` 存储图片;该凭证在 prompt 准入时解析为这张图片,命令只接受文件凭证。Host 调用方传入具有运行时所属上下文的 Agent;凭证表以该 Agent 的准确 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 | 文件 | 职责 | |---|---| diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 8bf1145193..758aaa6d81 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -1,6 +1,7 @@ +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import type { FileUploadValue } from '../types.ts' +import type { FileUploadValue, ImageUploadValue } from '../types.ts' /** Browser request body accepted by the background file-upload service. */ export type FileUploadBody = Blob | ReadableStream @@ -30,4 +31,24 @@ export interface FileUploadService { signal?: AbortSignal, onProgress?: (progress: FileUploadProgress) => void, ): Promise> + /** + * Store one prompt image for a Session through the background carrier. The + * Host validates, normalizes, and stores it as prompt admission would, and a + * later prompt cites the receipt in a file part. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser image Blob. + * @param mediaType - declared image media type. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer. + * @returns the staged receipt and normalized image reference, or a business error. + */ + uploadImage( + sessionId: SessionId, + data: Blob, + mediaType: ImageMediaType, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise> } diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index 556da15f10..46581c2873 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -1,13 +1,14 @@ /** Background browser upload implementation for Blob and byte-stream bodies. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment/types' import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { FILE_UPLOAD_ROUTE } from '../protocol.ts' import type { - ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, + ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, ImageUploadValue, } from '../types.ts' import type { FileUploadBody, FileUploadService } from './contract.ts' @@ -198,17 +199,7 @@ export class FileUploadRuntime extends Service implements FileUploadService { if (!(data instanceof Uint8Array)) { const query = new URLSearchParams({ sessionId }) if (name !== undefined) query.set('name', name) - const response = await this.post({ - path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, - body: data, - headers: { 'content-type': 'application/octet-stream' }, - ...(signal === undefined ? {} : { signal }), - ...(onProgress === undefined ? {} : { onProgress }), - }) - if (response.status !== 200) { - throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) - } - return parseFileUploadResult(response.body) + return parseUploadResult(await this.postRaw(query, data, signal, onProgress), fileUploadValue) } return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( sessionId, @@ -219,6 +210,49 @@ export class FileUploadRuntime extends Service implements FileUploadService { signal, ) } + + /** + * Store one prompt image for a Session. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser image Blob. + * @param mediaType - declared image media type. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer. + * @returns the staged receipt and normalized image reference, or a business error. + */ + async uploadImage( + sessionId: SessionId, + data: Blob, + mediaType: ImageMediaType, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + const query = new URLSearchParams({ sessionId, mediaType }) + if (name !== undefined) query.set('name', name) + return parseUploadResult(await this.postRaw(query, data, signal, onProgress), imageUploadValue) + } + + /** Post one raw body to the upload route; a non-200 status is a transport failure. */ + private async postRaw( + query: URLSearchParams, + body: FileUploadBody, + signal: AbortSignal | undefined, + onProgress: ((progress: { readonly loaded: number; readonly total?: number }) => void) | undefined, + ): Promise { + const response = await this.post({ + path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, + body, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return response.body + } } function customTransport(customFetch: FileUploadFetch): FileUploadTransport { @@ -297,7 +331,7 @@ function workerTransport(): FileUploadTransport { } } -function parseFileUploadResult(body: string): RemoteResult { +function parseUploadResult(body: string, parseValue: (value: unknown) => T): RemoteResult { const value: unknown = JSON.parse(body) if (!isRecord(value) || typeof value.ok !== 'boolean') { throw new TypeError('file upload transport returned an invalid result') @@ -313,26 +347,60 @@ function parseFileUploadResult(body: string): RemoteResult { error: new RemoteError(error.code as never, error.message, error.details as never), } } - const result = value.value + return { ok: true, value: parseValue(value.value) } +} + +function fileUploadValue(result: unknown): FileUploadValue { const file = isRecord(result) ? result.file : undefined if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) - || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' - || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' || !isNonNegativeInteger(file.bytes)) { throw new TypeError('file upload transport returned an invalid receipt') } return { - ok: true, - value: { - receiptId: result.receiptId as FileUploadValue['receiptId'], - file: { - attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], - name: file.name, - bytes: file.bytes, - }, + receiptId: result.receiptId as FileUploadValue['receiptId'], + file: { + attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, }, } } +function imageUploadValue(result: unknown): ImageUploadValue { + const image = isRecord(result) ? result.image : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(image) + || typeof image.attachmentId !== 'string' || !isImageMediaType(image.mediaType) + || !isNonNegativeInteger(image.bytes) || !isNonNegativeInteger(image.width) || !isNonNegativeInteger(image.height) + || (image.name !== undefined && typeof image.name !== 'string') + || (image.originalDimensions !== undefined && !isDimensions(image.originalDimensions))) { + throw new TypeError('file upload transport returned an invalid image receipt') + } + return { + receiptId: result.receiptId as ImageUploadValue['receiptId'], + image: { + attachmentId: image.attachmentId as ImageUploadValue['image']['attachmentId'], + mediaType: image.mediaType, + bytes: image.bytes, + width: image.width, + height: image.height, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.originalDimensions === undefined ? {} : { originalDimensions: image.originalDimensions }), + }, + } +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function isImageMediaType(value: unknown): value is ImageMediaType { + return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' +} + +function isDimensions(value: unknown): value is { width: number; height: number } { + return isRecord(value) && isNonNegativeInteger(value.width) && isNonNegativeInteger(value.height) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/client/file-upload/src/http-route.ts b/packages/client/file-upload/src/http-route.ts index 0f5539324e..b03c4b9562 100644 --- a/packages/client/file-upload/src/http-route.ts +++ b/packages/client/file-upload/src/http-route.ts @@ -4,17 +4,18 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { FileUploads } from './index.ts' -import type { FileUploadValue } from './types.ts' +import type { FileUploadValue, ImageUploadValue } from './types.ts' type FileUploadHttpResult = - | { readonly ok: true; readonly value: FileUploadValue } + | { readonly ok: true; readonly value: FileUploadValue | ImageUploadValue } | { readonly ok: false readonly error: { readonly code: string; readonly message: string; readonly details: object } } /** - * Handle one authenticated raw-byte upload. + * Handle one authenticated raw-byte upload. A `mediaType` query parameter + * stages the body as a prompt image; without it the body is stored verbatim. * @param service - Host upload service receiving streamed bytes. * @param request - authenticated HTTP request from Connection. * @returns JSON result using HTTP status 200 after request validation. @@ -33,16 +34,20 @@ export async function handleFileUploadHttp(service: FileUploads, request: Reques return new Response('sessionId is required', { status: 400 }) } const name = url.searchParams.get('name') ?? undefined + const imageMediaType = url.searchParams.get('mediaType') ?? undefined + const upload = { + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + } let result: FileUploadHttpResult try { result = { ok: true, - value: await service.uploadStream({ - sessionId: brandString(sessionId), - data: requestBodyChunks(request.body), - signal: request.signal, - ...(name === undefined ? {} : { name }), - }), + value: imageMediaType === undefined + ? await service.uploadStream(upload) + : await service.uploadImageStream({ ...upload, mediaType: imageMediaType }), } } catch (error) { const failure = remoteErrorOf(error) diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index acf99f22d2..e3ee710de1 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -3,26 +3,28 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-client-connection' import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, remoteErrorOf, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { handleFileUploadHttp } from './http-route.ts' import { FILE_UPLOAD_PATH } from './protocol.ts' -import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' +import type { + EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue, ImageUploadValue, StagedAttachment, +} from './types.ts' export type * from './types.ts' declare module '@deepseek-ai/cordis' { interface Context { - /** Host storage and staged-receipt service for browser file uploads. */ + /** Host storage and staged-receipt service for browser file and image uploads. */ fileUploads: FileUploads } } interface StagedFileUpload { - readonly file: FileAttachmentRef + readonly attachment: StagedAttachment /** Prompt that accepted this receipt; absent until successful admission. */ requestId?: string } @@ -65,8 +67,11 @@ export class FileUploads extends TypertRemoteService { /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ constructor(ctx: Context) { super(ctx, 'fileUploads') - const resolve: CommandFileReceiptResolver = (agent, receiptId) => - this.resolve(agent, receiptId as FileUploadReceiptId) + // Commands accept staged files only; an image receipt is unknown to them. + const resolve: CommandFileReceiptResolver = (agent, receiptId) => { + const staged = this.resolve(agent, receiptId as FileUploadReceiptId) + return staged?.type === 'file' ? staged.attachment : undefined + } ctx.effect( () => ctx.commands.registerFileReceiptResolver(resolve), 'file-upload: command file receipt resolver', @@ -105,12 +110,13 @@ export class FileUploads extends TypertRemoteService { * @returns the staged receipt and durable file reference. */ @Remote('upload') - upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { + async upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({ + const file = await this.store(agent, async () => this.ctx.attachments.admitEncodedFile({ data: request.data, ...(request.name === undefined ? {} : { name: request.name }), })) + return { receiptId: this.stage(agent, { type: 'file', attachment: file }), file } } /** @@ -125,21 +131,60 @@ export class FileUploads extends TypertRemoteService { readonly name?: string }): Promise { const agent = await this.resolveAgent(request.sessionId) - return this.commit(agent, async () => this.ctx.attachments.saveFileStream({ + const file = await this.store(agent, async () => this.ctx.attachments.saveFileStream({ data: request.data, ...(request.signal === undefined ? {} : { signal: request.signal }), ...(request.name === undefined ? {} : { name: request.name }), })) + return { receiptId: this.stage(agent, { type: 'file', attachment: file }), file } + } + + /** + * Persist one prompt image for a Session: read at most the deployment's + * per-image byte limit, then validate, normalize, and store the image the + * way prompt admission does, so a later prompt cites it by receipt. + * @param request - Session identity, ordered bytes, the declared media type that this deployment + * must accept, cancellation, and optional display name. + * @returns the staged receipt and durable normalized image reference. + */ + async uploadImageStream(request: { + readonly sessionId: SessionId + readonly data: AsyncIterable + readonly mediaType: string + readonly signal?: AbortSignal + readonly name?: string + }): Promise { + const agent = await this.resolveAgent(request.sessionId) + const limits = this.ctx.attachments.imageLimits + const mediaType = limits.mediaTypes.find(type => type === request.mediaType) + if (mediaType === undefined) { + throw new RemoteError( + 'session/attachment-invalid' as never, + `Image type ${request.mediaType} is not accepted by this deployment.`, + { reason: 'UNSUPPORTED_IMAGE_TYPE' } as never, + ) + } + const image = await this.store(agent, async () => { + const data = await readAtMost(request.data, limits.maxImageBytes, request.signal) + const refs = await this.ctx.attachments.saveImages([{ + data, + mediaType, + ...(request.name === undefined ? {} : { name: request.name }), + }]) + // saveImages returns one reference per input, in input order. + return refs[0] as ImageAttachmentRef + }) + return { receiptId: this.stage(agent, { type: 'image', attachment: image }), image } } /** * Resolve one staged receipt for the receiving Agent's exact Session. * @param agent - receiving Agent. * @param receiptId - opaque receipt minted for one completed upload. - * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + * @returns the staged file or image, or `undefined` for an unknown or foreign receipt. */ - resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { - return this.stagedFiles.get(agent.session)?.get(receiptId)?.file + resolve(agent: Agent, receiptId: FileUploadReceiptId): StagedAttachment | undefined { + return this.stagedFiles.get(agent.session)?.get(receiptId)?.attachment } /** @@ -179,12 +224,13 @@ export class FileUploads extends TypertRemoteService { this.retire(agent.session, requestId) } - private async commit(agent: Agent, save: () => Promise): Promise { + /** Store one upload for an ordinary Agent; storage failures become Remote failures. */ + private async store(agent: Agent, save: () => Promise): Promise { this.assertOrdinaryAgent(agent) - let file: FileAttachmentRef try { - file = await save() + return await save() } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error if (this.ctx.attachments.isAttachmentError(error)) { throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never) } @@ -195,6 +241,10 @@ export class FileUploads extends TypertRemoteService { { cause: error }, ) } + } + + /** Stage one stored upload under a new receipt for the Agent's exact Session. */ + private stage(agent: Agent, attachment: StagedAttachment): FileUploadReceiptId { if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -208,8 +258,8 @@ export class FileUploads extends TypertRemoteService { this.stagedFiles.set(agent.session, staged) } const receiptId = randomUUID() as FileUploadReceiptId - staged.set(receiptId, { file }) - return { receiptId, file } + staged.set(receiptId, { attachment }) + return receiptId } private async resolveAgent(sessionId: SessionId): Promise { @@ -248,6 +298,31 @@ export class FileUploads extends TypertRemoteService { } } +/** Collect one body of at most `limit` bytes, refusing it as soon as it passes the limit. */ +async function readAtMost(data: AsyncIterable, limit: number, signal?: AbortSignal): Promise { + const chunks: Uint8Array[] = [] + let length = 0 + for await (const chunk of data) { + signal?.throwIfAborted() + length += chunk.byteLength + if (length > limit) { + throw new RemoteError( + 'session/attachment-invalid' as never, + 'Image exceeds the configured byte limit.', + { reason: 'IMAGE_TOO_LARGE' } as never, + ) + } + chunks.push(chunk) + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + function fileNotStaged(): RemoteError { return new RemoteError( 'session/attachment-invalid' as never, diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts index ff153925af..bb7565ba87 100644 --- a/packages/client/file-upload/src/types.ts +++ b/packages/client/file-upload/src/types.ts @@ -1,6 +1,6 @@ /** Browser-safe request and receipt types for staged file uploads. */ -import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' /** Canonical encoded upload accepted by the Remote fallback. */ @@ -18,6 +18,19 @@ export interface FileUploadValue { readonly file: FileAttachmentRef } +/** Durable receipt for one staged image upload. */ +export interface ImageUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId + /** The normalized prompt image the Host stored. */ + readonly image: ImageAttachmentRef +} + +/** Durable attachment one staged receipt names: a verbatim file, or a normalized prompt image. */ +export type StagedAttachment = + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + /** Host-minted authority for one staged file upload in one Agent scope. */ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> diff --git a/packages/client/file-upload/tests/file-upload-http.host.spec.ts b/packages/client/file-upload/tests/file-upload-http.host.spec.ts index cd137857ce..ed34e34d0e 100644 --- a/packages/client/file-upload/tests/file-upload-http.host.spec.ts +++ b/packages/client/file-upload/tests/file-upload-http.host.spec.ts @@ -9,12 +9,14 @@ function request(input: { method?: string sessionId?: string name?: string + mediaType?: string contentType?: string body?: Uint8Array } = {}): Request { const query = new URLSearchParams() if (input.sessionId !== undefined) query.set('sessionId', input.sessionId) if (input.name !== undefined) query.set('name', input.name) + if (input.mediaType !== undefined) query.set('mediaType', input.mediaType) const suffix = query.size === 0 ? '' : `?${query.toString()}` return new Request(`http://host/api/session/uploadFileBinary${suffix}`, { method: input.method ?? 'POST', @@ -91,6 +93,27 @@ describe('background file upload Fetch route', () => { expect(await response.json()).toEqual({ ok: true, value }) }) + it('stages the body as a prompt image when the request names its media type', async () => { + const value: Awaited> = { + receiptId: 'receipt-2' as never, + image: { attachmentId: 'image-1' as never, mediaType: 'image/png', bytes: 2, width: 1, height: 1 }, + } + const imageChunks: Uint8Array[] = [] + const uploadImageStream = vi.fn(async (input) => { + for await (const chunk of input.data) imageChunks.push(chunk) + return value + }) + const service = Object.assign(uploads(Promise.resolve({})), { uploadImageStream }) + const response = await handleFileUploadHttp(service, request({ + sessionId: 's1', name: 'shot.png', mediaType: 'image/png', + contentType: 'application/octet-stream', body: Uint8Array.of(7, 8), + })) + expect(service.uploadStream).not.toHaveBeenCalled() + expect(uploadImageStream.mock.calls[0]?.[0]).toMatchObject({ sessionId: 's1', name: 'shot.png', mediaType: 'image/png' }) + expect(imageChunks).toEqual([Uint8Array.of(7, 8)]) + expect(await response.json()).toEqual({ ok: true, value }) + }) + it('returns business and internal storage failures and keeps an absent name absent', async () => { const business = uploads(Promise.reject(new RemoteError( 'session/attachment-invalid', 'denied', { reason: 'NOPE' }, diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 2392feed55..3a4e0b22ea 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -492,4 +492,53 @@ describe('Session-addressed file upload', () => { }) await failed.fiber.dispose() }) + + it('uploads an image with its media type and parses the stored image', async () => { + const image = { + attachmentId: 'image-1', mediaType: 'image/png', bytes: 4, width: 2, height: 2, + name: 'shot.png', originalDimensions: { width: 4, height: 4 }, + } + const fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + ok: true, value: { receiptId: 'receipt-2', image }, + }), { status: 200 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const { fiber, service } = await scopedService() + const file = new Blob(['data'], { type: 'image/png' }) + await expect(service.uploadImage(SESSION_ID, file, 'image/png', 'shot.png')).resolves.toEqual({ + ok: true, value: { receiptId: 'receipt-2', image }, + }) + expect(fetch).toHaveBeenCalledWith( + 'api/session/uploadFileBinary?sessionId=s1&mediaType=image%2Fpng&name=shot.png', + expect.objectContaining({ method: 'POST', body: file }), + ) + await fiber.dispose() + + const plain = { attachmentId: 'image-2', mediaType: 'image/webp', bytes: 1, width: 1, height: 1 } + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ + ok: true, value: { receiptId: 'receipt-4', image: plain }, + }), { status: 200 })), + } + const unnamed = await scopedService() + await expect(unnamed.service.uploadImage(SESSION_ID, file, 'image/webp')).resolves.toEqual({ + ok: true, value: { receiptId: 'receipt-4', image: plain }, + }) + await unnamed.fiber.dispose() + + for (const malformed of [ + null, + { receiptId: 'receipt-3', image: { ...image, mediaType: 'image/bmp' } }, + { receiptId: 'receipt-3', image: { ...image, width: -1 } }, + { receiptId: 'receipt-3', image: { ...image, name: 3 } }, + { receiptId: 'receipt-3', image: { ...image, originalDimensions: { width: 1 } } }, + ]) { + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ ok: true, value: malformed }), { status: 200 })), + } + const rejected = await scopedService() + await expect(rejected.service.uploadImage(SESSION_ID, file, 'image/png')) + .rejects.toThrow('file upload transport returned an invalid image receipt') + await rejected.fiber.dispose() + } + }) }) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 123b686b4e..0481d6a8c5 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -18,8 +18,8 @@ en: 15cdd9d52dcc71b8 zh: 53933fbbf5d39100 /deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments: - en: a77c8ec48e219aec - zh: 756d4fe881403006 + en: ce7e8e06805a6d66 + zh: de1cb8bd06e3e560 /deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox: en: 8b3d7729152d8060 zh: 815f932ccc9653f5 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 4fee29e123..2f28c9c2ce 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) an ### Draft attachments -Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. An uploading image shows the same progress bar along its thumbnail's bottom edge, and a failed image upload turns the thumbnail into its retry control. Clicking an image opens the original. ### Message images and the lightbox diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 706a6d97c7..a708581b93 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 草稿附件 -图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。上传中的图片在缩略图底边显示同样的进度条,上传失败的图片缩略图变成重试控件。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index adb40af60f..962148b937 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -30,6 +30,53 @@ object-fit: cover; } +.thumbnailFailed { + border-color: var(--dsw-alias-state-error-primary, #d54941); + cursor: pointer; +} + +.thumbnailFailed img { + opacity: 0.4; +} + +.retryGlyph { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--dsw-alias-state-error-primary, #d54941); +} + +.progressTrack { + position: absolute; + right: 8px; + bottom: 6px; + left: 8px; + overflow: hidden; + height: 2px; + border-radius: 1px; + background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); + pointer-events: none; +} + +.progressBar { + display: block; + width: 35%; + height: 100%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: image-upload-progress 1.2s ease-in-out infinite alternate; +} + +.progressBar[style] { + animation: none; +} + +@keyframes image-upload-progress { + from { transform: translateX(-70%); } + to { transform: translateX(220%); } +} + .remove { position: absolute; top: 4px; @@ -70,4 +117,8 @@ .remove { transition: none; } + + .progressBar { + animation: none; + } } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 0065410b9d..f8dac0c690 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' @@ -71,16 +71,44 @@ export function ComposerAttachments({ /> ) } + // An uploading image shows its progress; a failed upload turns the thumbnail into its retry control. + const upload = uploads[attachment.id] return (
- + {upload?.status === 'error' + ? ( + + ) + : ( + + )} + {upload?.status === 'uploading' && ( + + + + )} + +
) - : ( - - )} + : upload?.status === 'error' + ? ( + + ) + : ( + + )} {upload?.status === 'uploading' && ( { onRemoveAttachment(attachment.id) }} > diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index ba5ea67ada..a419e83f3c 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -74,7 +74,7 @@ function fileDraft(id: string, name = `${id}.pdf`): ComposerAttachment { } } -function props(overrides: Partial = {}): ComposerAttachmentsProps { +function props(overrides: Partial = {}): Parameters[0] { return { attachments: [], canAcceptDrop: true, @@ -82,9 +82,10 @@ function props(overrides: Partial = {}): Composer onRemoveAttachment: () => {}, uploads: {}, onRetryFile: () => {}, + uploadFailureText: (reason: string) => `refused:${reason}`, t, ...overrides, - } as unknown as ComposerAttachmentsProps + } } describe('ComposerAttachments', () => { @@ -230,18 +231,18 @@ describe('ComposerAttachments', () => { }) describe('ComposerAttachments image uploads', () => { - it('shows image upload progress and turns a failed image into its retry control', () => { + it('shows image upload progress, retries a failed image, and explains one the Host refused', () => { const onRetryFile = vi.fn() const view = render() @@ -251,8 +252,23 @@ describe('ComposerAttachments image uploads', () => { expect(view.getByTitle('上传失败,点击重试')).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '重试上传 failed.png' })) expect(onRetryFile).toHaveBeenCalledWith('failed') + // A refusal names its reason and offers no retry; removal stays available. + expect(view.getByRole('img', { name: 'refused:IMAGE_DIMENSION_TOO_LARGE' }).getAttribute('title')) + .toBe('refused:IMAGE_DIMENSION_TOO_LARGE') + expect(view.queryByRole('button', { name: '重试上传 refused.png' })).toBeNull() + expect(view.getByRole('button', { name: '移除图片 refused.png' })).toBeTruthy() expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + + it('names an image without a file name in its preview, retry, and remove controls', () => { + const view = render() + expect(view.getByRole('button', { name: '重试上传 待发送图片' })).toBeTruthy() + expect(view.getAllByRole('button', { name: '移除图片 待发送图片' })).toHaveLength(2) + expect(view.getByAltText('待发送图片')).toBeTruthy() + }) }) describe('ComposerAttachments file drafts', () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 423b12ad0e..19576f04ed 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -368,10 +368,13 @@ export function apply(ctx: Context, config: Config = Config({})): void { const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { - if (sessions.binding(nextId) === undefined) { + const binding = sessions.binding(nextId) + if (binding === undefined) { throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) } - concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds) + // The same rule as intake: only an ordinary Session stages image uploads. + const ordinary = binding.session.getSnapshot().subagent === null + concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds, ordinary) if (draft.text !== '') { next.setDraft(draft) from.setDraft('') diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 574b368537..6f638b2371 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -53,13 +53,15 @@ export interface ComposerFileAttachment { /** * Upload lifecycle of one draft that uploads when it is added, not on send: - * every file draft, and every image draft of an ordinary Session. + * every file draft, and every image draft of an ordinary Session. A failed + * upload the Host refused keeps the attachment refusal `reason`, because + * retrying the same bytes cannot succeed. */ export type DraftFileUpload = | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number } | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef } - | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly image: ImageAttachmentRef } - | { readonly status: 'error'; readonly message: string } + | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId } + | { readonly status: 'error'; readonly message: string; readonly reason?: string } /** Per-draft upload states keyed by draft attachment id. */ export type DraftFileUploads = Readonly> @@ -82,6 +84,13 @@ export interface ComposerAttachmentsOwnerProps { uploads: DraftFileUploads /** Restart one failed file upload. */ onRetryFile: (id: DraftAttachmentId) => void + /** + * Explain one upload the Host refused; such a draft cannot be retried and + * stays until it is removed. + * @param reason - the refusal `reason` the failed upload carries. + * @returns localized text naming the limit or the way out. + */ + uploadFailureText: (reason: string) => string /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index dacb1796b8..524c8a37ea 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -2,7 +2,6 @@ import type { MouseEvent, MutableRefObject, RefObject } from 'react' import type { LexicalEditor } from 'lexical' import type { ComposerKeyboard } from '../../contract/draft-editor.ts' -import type { ComposerBarProps } from '../../contract/slots.ts' import type { BusyEnterBehavior } from '../../contract/composer-submission.ts' import { resolveSubmitMode } from '../submission-policy.ts' import { registerComposerKeymap } from './keymap.ts' @@ -16,8 +15,9 @@ interface DraftViewGate { busyEnter: BusyEnterBehavior intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void uploadsPending: boolean + /** Toast text explaining why pending uploads hold the send. */ + uploadsHeldText: string showToast: (text: string) => void - t: ComposerBarProps['t'] canAcceptDrop: boolean } @@ -126,7 +126,7 @@ export function installDraftKeymap( return } if (g.uploadsPending) { - g.showToast(g.t('file.stillUploading')) + g.showToast(g.uploadsHeldText) return } keyboard.submit(resolveSubmitMode( diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index ff333a1781..41eabe4132 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -348,11 +348,14 @@ export class ConversationController extends Service implements IConversation { * Stage carried file and image drafts again for a new Session. * @param sessionId - target Agent-scope identity after a Workspace switch. * @param ids - carried draft attachment ids. + * @param uploadImages - whether the target Session stages image uploads; an image draft otherwise sends inline. */ - rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void { + rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[], uploadImages: boolean): void { for (const id of ids) { const attachment = this.draftAttachments.get(id) - if (attachment !== undefined) this.beginFileUpload(sessionId, attachment) + if (attachment === undefined) continue + if (attachment.kind === 'image' && !uploadImages) this.dropUpload(id) + else this.beginFileUpload(sessionId, attachment) } } @@ -391,11 +394,17 @@ export class ConversationController extends Service implements IConversation { if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return this.fileUploads.update((draft) => { if (!(attachment.id in draft)) return - draft[attachment.id] = !result.ok - ? { status: 'error', message: result.error.message } - : 'image' in result.value - ? { status: 'ready', receiptId: result.value.receiptId, image: result.value.image } - : { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + if (result.ok) { + draft[attachment.id] = 'file' in result.value + ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + : { status: 'ready', receiptId: result.value.receiptId } + return + } + const { error } = result + // A Host refusal keeps its reason: retrying the same bytes cannot succeed. + draft[attachment.id] = error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-invalid' + ? { status: 'error', message: error.message, reason: error.details.reason } + : { status: 'error', message: error.message } }) } catch (error) { if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return @@ -446,8 +455,9 @@ export class ConversationController extends Service implements IConversation { /** * Serialize ordered draft attachments to command-submit wire payloads without - * sending or releasing them. Images are encoded; generic files cite receipts - * from their completed background uploads and never reread browser bytes. + * sending or releasing them. Images are encoded even when an upload already + * stored them, because commands accept only file receipts; generic files cite + * receipts from their completed background uploads and never reread browser bytes. * @param attachmentIds - ordered draft-local attachment ids. * @returns wire payloads in id order. */ @@ -478,12 +488,17 @@ export class ConversationController extends Service implements IConversation { releaseDraftAttachment(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return - const operation = this.fileUploadOperations.get(id) - this.fileUploadOperations.delete(id) - operation?.controller.abort() this.draftAttachments.delete(id) if (attachment.kind === 'image') revokePreview(attachment.previewUrl) // The stored Host object stays durable; only the draft's upload state ends. + this.dropUpload(id) + } + + /** Abort one draft's active upload and drop its upload state. */ + private dropUpload(id: DraftAttachmentId): void { + const operation = this.fileUploadOperations.get(id) + this.fileUploadOperations.delete(id) + operation?.controller.abort() this.forgetUpload(id) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index bb303489b2..e53e666864 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -99,6 +99,11 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection('imageLimits') + // Enter while uploads hold the send explains the first failed draft; a Host refusal names its reason. + const failedUpload = attachments.map(attachment => uploads[attachment.id]).find(upload => upload?.status === 'error') + const uploadsHeldText = failedUpload?.status !== 'error' + ? t('file.stillUploading') + : failedUpload.reason === undefined ? t('file.uploadFailed') : attachmentErrorText(t, failedUpload.reason, imageLimits) // Prompt failures are ordinary failures (no create/attach transaction exists // anymore): the toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds @@ -253,11 +258,11 @@ export const InputBar = memo(function InputBar({ // registration survives re-renders without re-arming per keystroke. const gate = useRef({ locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, canAcceptDrop, + intakeFiles, uploadsPending, uploadsHeldText, showToast, canAcceptDrop, }) gate.current = { locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, canAcceptDrop, + intakeFiles, uploadsPending, uploadsHeldText, showToast, canAcceptDrop, } useEffect(() => { @@ -412,6 +417,7 @@ export const InputBar = memo(function InputBar({ onRemoveAttachment: (id) => { removeAttachment?.(id) }, uploads, onRetryFile: (id) => { retryFileUpload?.(id) }, + uploadFailureText: reason => attachmentErrorText(t, reason, imageLimits), dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 48d1ef4bbb..e308d326da 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1121,6 +1121,38 @@ describe('running and lock semantics', () => { expect(inline.button.disabled).toBe(false) }) + it('explains on Enter why uploads hold the send, naming a Host refusal', () => { + const limits = { + maxImageBytes: 1024 * 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'] as const, + } + const image = { + kind: 'image' as const, + id: 'image-1' as DraftAttachmentId, + file: new File(['x'], 'wide.png', { type: 'image/png' }), + previewUrl: 'blob:wide', + } + const enterWith = (upload: NonNullable[string]): string | null => { + const result = bench({ draft: '带图', attachments: [image], fileUploads: { [image.id]: upload }, imageLimits: limits }) + fireEvent.keyDown(result.textarea, { key: 'Enter' }) + expect(result.sink).not.toHaveBeenCalled() + const text = result.view.getByRole('alert').textContent + cleanup() + return text + } + expect(enterWith({ status: 'uploading', loaded: 0 })).toContain('文件还在上传,请等待上传完成后发送') + expect(enterWith({ status: 'error', message: 'offline' })).toContain('上传失败,点击重试') + const refusal = '图片宽高不能超过 2000px,请缩小后重试' + expect(enterWith({ status: 'error', message: 'too wide', reason: 'IMAGE_DIMENSION_TOO_LARGE' })).toContain(refusal) + // The attachments slot receives the same localized explanation for its thumbnail. + const owner = attachmentOwner(bench({ attachments: [image], imageLimits: limits }).slotCalls) + expect(owner.uploadFailureText('IMAGE_DIMENSION_TOO_LARGE')).toBe(refusal) + }) + it('idle Send keeps the plain label regardless of the busy-state preference', () => { const { button, sink } = bench({ busyEnter: 'steer', draft: '空闲发送' }) expect(button.getAttribute('aria-label')).toBe('发送消息') 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 4d9f97cfd8..76b93bd95a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -209,7 +209,7 @@ describe('ConversationController', () => { await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready') expect(b.root.fileUploads.getSnapshot()[imageDraft.id]) - .toEqual({ status: 'ready', receiptId: 'image-receipt-1', image: IMAGE_REF }) + .toEqual({ status: 'ready', receiptId: 'image-receipt-1' }) }) expect(uploadFile).toHaveBeenCalledOnce() expect(uploadFile.mock.calls[0]?.[0]).toBe(fileDraft.file) @@ -242,8 +242,10 @@ describe('ConversationController', () => { const [draft] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'big.png', { type: 'image/png' }), ], UPLOAD_IMAGES) + // A Host refusal keeps its reason; retrying stays available to the service. await vi.waitFor(() => { - expect(b.root.fileUploads.getSnapshot()[draft!.id]).toEqual({ status: 'error', message: 'too big' }) + expect(b.root.fileUploads.getSnapshot()[draft!.id]) + .toEqual({ status: 'error', message: 'too big', reason: 'IMAGE_TOO_LARGE' }) }) b.root.retryFileUpload(session.sessionId, draft!.id) await vi.waitFor(() => { expect(uploadImage).toHaveBeenCalledTimes(2) }) @@ -259,6 +261,26 @@ describe('ConversationController', () => { await b.runtime.dispose() }) + it('keeps no refusal reason when an image upload fails in transport', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:flaky') + try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadImage?: unknown }).uploadImage = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'connection reset', {}), + })) + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'flaky.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toEqual({ status: 'error', message: 'connection reset' }) + }) + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('serializes command files as staged receipts without reading their bytes again', async () => { const b = await bench() const session = b.runtime.sessions.binding('s1')!.session @@ -397,6 +419,38 @@ describe('ConversationController', () => { await b.runtime.dispose() }) + it('drops a carried image upload when the target Session sends images inline', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:carry') + try { + const source = b.runtime.sessions.binding('s1')!.session + let sourceSignal: AbortSignal | undefined + ;(source as { uploadImage?: unknown }).uploadImage = vi.fn( + (_data: Blob, _mediaType: string, _name: string | undefined, signal: AbortSignal) => { + sourceSignal = signal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + }, + ) + const [image] = b.root.createDrafts(source.sessionId, [ + new File([Uint8Array.of(5)], 'carry.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await vi.waitFor(() => { expect(sourceSignal).toBeDefined() }) + const targetUpload = vi.fn() + await b.runtime.sessions.add({ id: 's2', session: { uploadImage: targetUpload } }) + using _target = b.runtime.sessions.retain('s2' as SessionId) + await _target.ready + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [image!.id], INLINE_IMAGES) + expect(sourceSignal?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[image!.id]).toBeUndefined() + expect(targetUpload).not.toHaveBeenCalled() + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('cancels a superseded upload and stages the carried draft on the target Session', async () => { const b = await bench() const source = b.runtime.sessions.binding('s1')!.session @@ -428,7 +482,7 @@ describe('ConversationController', () => { await b.runtime.sessions.add({ id: 's2', session: target }) using _target = b.runtime.sessions.retain('s2' as SessionId) await _target.ready - b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id]) + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id], UPLOAD_IMAGES) expect(sourceSignal?.aborted).toBe(true) await vi.waitFor(() => { 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 baf1d8f505..59f9f66f8c 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -659,7 +659,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:279', }, { key: 'conversation.composer.bar', @@ -701,7 +701,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:301', }, { key: 'conversation.composer.dock', @@ -761,7 +761,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:293', }, { key: 'conversation.header', @@ -799,7 +799,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:220', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:229', }, { key: 'conversation.header.leading', @@ -830,7 +830,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:249', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:258', }, { key: 'conversation.hero.agentPreset', @@ -870,7 +870,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:285', }, { key: 'conversation.hero.brand.mark', @@ -901,7 +901,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:283', }, { key: 'conversation.hero.options', @@ -958,7 +958,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:287', }, { key: 'conversation.hero.workspace', @@ -993,7 +993,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:281', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -1071,7 +1071,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:299', }, { key: 'conversation.input.attachments', @@ -1081,7 +1081,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /**\n * Explain one upload the Host refused; such a draft cannot be retried and\n * stays until it is removed.\n * @param reason - the refusal `reason` the failed upload carries.\n * @returns localized text naming the limit or the way out.\n /* …truncated — full shape in source */', ], ownerPropsReferences: [ 'ComposerAttachment', @@ -1115,7 +1115,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:303', }, { key: 'conversation.input.dock', @@ -1182,7 +1182,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:289', }, { key: 'conversation.input.left', @@ -1239,7 +1239,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:286', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:295', }, { key: 'conversation.input.model', @@ -1281,7 +1281,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:308', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:317', }, { key: 'conversation.input.model.label', @@ -1387,7 +1387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:291', }, { key: 'conversation.input.permission', @@ -1429,7 +1429,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:302', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:311', }, { key: 'conversation.input.plan', @@ -1471,7 +1471,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:300', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:309', }, { key: 'conversation.input.right', @@ -1528,7 +1528,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:288', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:297', }, { key: 'conversation.plan-review.actions', @@ -1629,7 +1629,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223', }, { key: 'conversation.session.header', @@ -1669,7 +1669,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:222', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231', }, { key: 'conversation.session.header.actions', @@ -1733,7 +1733,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:246', }, { key: 'conversation.session.header.corner', @@ -1775,7 +1775,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:269', }, { key: 'conversation.session.header.lineage', @@ -1819,7 +1819,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:240', }, { key: 'conversation.session.header.utilities', @@ -1882,7 +1882,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:243', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:252', }, { key: 'conversation.session.header.views.actions', @@ -1936,7 +1936,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.views.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.views.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:275', }, { key: 'conversation.trajectory.images', @@ -2038,7 +2038,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.user.text.node\', () => ctx.slots.register(\n { name: \'conversation.user.text.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', }, { key: 'conversation.view', @@ -2102,7 +2102,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:277', }, { key: 'deliverables.file.actions', @@ -2305,7 +2305,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:212', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:221', }, { key: 'plugins.add.actions', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index acc373796e..804bde1ff1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1163,7 +1163,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async uploadImageStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly mediaType: string readonly signal?: AbortSignal readonly name?: string }): Promise', - description: 'Persist one prompt image for a Session: read at most the deployment\'s per-image byte limit, then validate, normalize, and store the image the way prompt admission does, so a later prompt cites it by receipt. Cancellation before storage begins stores nothing; a cancelled upload never receives a receipt, although an image whose storage already began stays in the content-addressed store.', + description: 'Persist one prompt image for a Session: read at most the smaller of the deployment\'s per-image and per-message byte limits, then validate, normalize, and store the image the way prompt admission does, so a later prompt cites it by receipt. Cancellation before storage begins stores nothing; a cancelled upload never receives a receipt, although an image whose storage already began stays in the content-addressed store.', parameters: [{ name: 'request', description: 'Session identity, ordered bytes, the declared media type that this deployment must accept, cancellation, and optional display name.' }], returns: 'the staged receipt and durable normalized image reference.', }, diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 9abfd131da..2cababe202 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -427,9 +427,6 @@ "packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx": { "b42ab24a43e0f7542bf1deb8f7ac3a76698d29e098f8dc575af6c08774987c32": 1 }, - "packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx": { - "ab22ebe23f59b5aa76d42502a0421f3953d1d170e8a4f900a0c442daf6f1397d": 1 - }, "packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts": { "a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1 }, From dbb2492c010c29dc8853ba2f99a837ca5cf5e779 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 21 Oct 2026 16:48:03 +0800 Subject: [PATCH 6/9] feat(attachment): relax the default image admission limits Normalization already downscales long sides, so the default per-side limit rises from 8192px to 65,535px, the largest side a JPEG carries, and the decoded-pixel limit alone bounds admission. The default per-image limit rises from 20 MiB to 50 MiB. --- docs/config-catalog.md | 6 +++--- docs/config-catalog.zh.md | 6 +++--- .../attachment/attachment-local/README.i18n.yaml | 4 ++-- packages/attachment/attachment-local/README.md | 4 ++-- packages/attachment/attachment-local/README.zh.md | 4 ++-- packages/attachment/attachment-local/src/index.ts | 14 +++++++++----- .../attachment-local/tests/index.spec.ts | 4 ++-- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 313b63aa2b..69f2b748f8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -354,14 +354,14 @@ export interface Config { ## `@deepseek-ai/dsh-attachment-local` -- `source`: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) +- `source`: [`packages/attachment/attachment-local/src/index.ts:65`](../packages/attachment/attachment-local/src/index.ts) ```ts config-catalog /** Local attachment backend configuration. */ export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 50 MiB. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number @@ -369,7 +369,7 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 65,535px. */ maxImageDimension?: number /** Total-pixel budget of the stored provider-independent normalized image. */ normalizedImageMaxPixels?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 56f50c6fe4..171587800d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -356,14 +356,14 @@ export interface Config { ## `@deepseek-ai/dsh-attachment-local` -- `source`: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) +- `source`: [`packages/attachment/attachment-local/src/index.ts:65`](../packages/attachment/attachment-local/src/index.ts) ```ts config-catalog /** Local attachment backend configuration. */ export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 50 MiB. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number @@ -371,7 +371,7 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 65,535px. */ maxImageDimension?: number /** Total-pixel budget of the stored provider-independent normalized image. */ normalizedImageMaxPixels?: number diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 520e5af483..25115adbcc 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -18,8 +18,8 @@ en: 912a19be4b59ff8a zh: 9c14694cf2ebbaf2 /deepseek-ai-dsh-attachment-local/use-this-package/minimal-configuration: - en: b34e09c3236f81f1 - zh: c541b5dc22c8f307 + en: 9597c3d9779cb93d + zh: 900baa94138cc7de /deepseek-ai-dsh-attachment-local/use-this-package/where-your-images-are-stored-and-how-long-they-last: en: 4ee90b8f74baa38c zh: 9c577709ec55eb82 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 4303813905..4f3bb81ba0 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -38,11 +38,11 @@ Mount the plugin with no required configuration. The defaults below define what | Field | Default | Meaning | |---|---|---| | `dshHome` | resolved | Explicit harness home; omitted follows `$DSH_HOME`, then `~/.dsh` | -| `maxImageBytes` | `20 MiB` | Maximum encoded source bytes accepted for one image | +| `maxImageBytes` | `50 MiB` | Maximum encoded source bytes accepted for one image | | `maxImagesPerMessage` | `20` | Maximum image count accepted in one submitted message | | `maxMessageImageBytes` | `200 MiB` | Maximum aggregate encoded source bytes in one submitted message | | `maxImagePixels` | `64,000,000` | Maximum source width multiplied by height | -| `maxImageDimension` | `8192` | Maximum source width or height | +| `maxImageDimension` | `65,535` | Maximum source width or height; the default leaves admission to `maxImagePixels`, and normalization downscales long sides | | `normalizedImageMaxPixels` | `2048 × 2048` | Total-pixel budget of the stored normalized image | | `normalizedImageMaxDimension` | `8192` | Maximum long edge after applying the total-pixel budget | | `normalizedImageMaxBytes` | `4 MiB` | Encoded-byte target; the smallest quality-ladder output is kept when none fits | diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 4c52dab3ac..fdfc7d9161 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -38,11 +38,11 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `dshHome` | 自动解析 | 显式 harness home;省略时依次跟随 `$DSH_HOME` 与 `~/.dsh` | -| `maxImageBytes` | `20 MiB` | 单张图片接受的最大编码源字节数 | +| `maxImageBytes` | `50 MiB` | 单张图片接受的最大编码源字节数 | | `maxImagesPerMessage` | `20` | 单条提交消息接受的最大图片数量 | | `maxMessageImageBytes` | `200 MiB` | 单条提交消息接受的最大编码源图字节总数 | | `maxImagePixels` | `64,000,000` | 源图宽度与高度乘积的最大值 | -| `maxImageDimension` | `8192` | 源图接受的最大宽度或高度 | +| `maxImageDimension` | `65,535` | 源图接受的最大宽度或高度;默认值让 `maxImagePixels` 单独把关,长边由规范化缩小 | | `normalizedImageMaxPixels` | `2048 × 2048` | 已存规范化图片的总像素预算 | | `normalizedImageMaxDimension` | `8192` | 应用总像素预算后的最大长边 | | `normalizedImageMaxBytes` | `4 MiB` | 编码字节目标;没有候选满足时保留质量阶梯中的最小输出 | diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 34959d088a..2965fef0cc 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -31,15 +31,19 @@ export type { PreparedImageFile } from './store.ts' export { readRequestImageFile, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ -export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 +export const DEFAULT_MAX_IMAGE_BYTES = 50 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 200 * 1024 * 1024 /** Default maximum intrinsic pixels for one submitted image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 -/** Default per-side pixel cap for one submitted image. */ -export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 +/** + * Default per-side pixel cap for one submitted image: the largest side a JPEG + * can carry. Normalization downscales a long side, so by default the + * decoded-pixel limit alone bounds admission. + */ +export const DEFAULT_MAX_IMAGE_DIMENSION = 65_535 /** * Default total-pixel budget of the stored normalized image. A larger source * is admitted and downscaled proportionally, so admission bounds what rides @@ -61,7 +65,7 @@ export const MAX_IMAGE_COMPRESSION_CONCURRENCY = 8 export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 50 MiB. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number @@ -69,7 +73,7 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 65,535px. */ maxImageDimension?: number /** Total-pixel budget of the stored provider-independent normalized image. */ normalizedImageMaxPixels?: number diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 00a427e16e..6482190ebe 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -21,11 +21,11 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(20 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(50 * 1024 * 1024) expect(DEFAULT_MAX_IMAGES_PER_MESSAGE).toBe(20) expect(DEFAULT_MAX_MESSAGE_IMAGE_BYTES).toBe(200 * 1024 * 1024) expect(DEFAULT_MAX_IMAGE_PIXELS).toBe(64_000_000) - expect(DEFAULT_MAX_IMAGE_DIMENSION).toBe(8192) + expect(DEFAULT_MAX_IMAGE_DIMENSION).toBe(65_535) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, From 85181cad37abd32e82e6012f276dc84fd6db5ae3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 21 Oct 2026 17:25:57 +0800 Subject: [PATCH 7/9] fix(web): show image upload failures in a tooltip A refused image thumbnail named its reason only through a native title, which browsers show late or not at all, so hovering it showed nothing. The refused and the failed image thumbnails now use the shared tooltip: the Host's refusal opens on hover or keyboard focus and a tap or click pins it, and the retry hint of a failed upload opens on hover or focus. --- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../src/client/ComposerAttachments.module.css | 1 - .../src/client/ComposerAttachments.tsx | 45 ++++++++++--------- .../composer-attachments.client.spec.tsx | 40 ++++++++++++++--- 6 files changed, 63 insertions(+), 31 deletions(-) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index c5d89995d3..ae497ad6ce 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -18,8 +18,8 @@ en: 15cdd9d52dcc71b8 zh: 53933fbbf5d39100 /deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments: - en: c7535c34db5d782c - zh: 4fc4caccac84261f + en: 8246cb3c470e3fc5 + zh: 7028cf466a832b99 /deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox: en: 8b3d7729152d8060 zh: 815f932ccc9653f5 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 5d3faadf45..0805928455 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) an ### Draft attachments -Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. An uploading image shows the same progress bar along its thumbnail's bottom edge, and a failed image upload turns the thumbnail into its retry control, except an image the Host refused, whose thumbnail names the refusal and leaves removal as the way out. Clicking an image opens the original. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. An uploading image shows the same progress bar along its thumbnail's bottom edge, and a failed image upload turns the thumbnail into its retry control, except an image the Host refused, whose thumbnail explains the refusal in a tooltip that a tap or click also pins and leaves removal as the way out. Clicking an image opens the original. ### Message images and the lightbox diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 64ac2dcfe5..c17f914b4d 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 草稿附件 -图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。上传中的图片在缩略图底边显示同样的进度条,上传失败的图片缩略图变成重试控件;Host 拒绝的图片则在缩略图上说明原因,只能移除。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。上传中的图片在缩略图底边显示同样的进度条,上传失败的图片缩略图变成重试控件;Host 拒绝的图片则用缩略图的提示说明原因,点按也能固定该提示,只能移除。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 1befbc9dce..2d2dc878f6 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -40,7 +40,6 @@ } .thumbnailRefused { - box-sizing: border-box; cursor: default; } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 7738662eeb..8035470f69 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -3,7 +3,7 @@ import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { - IconCloseFillRegular, IconRefreshOutlineRegular, IconWarningOutlineRegular, + IconCloseFillRegular, IconRefreshOutlineRegular, IconWarningOutlineRegular, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' @@ -79,7 +79,8 @@ export function ComposerAttachments({ ) } // An uploading image shows its progress; a failed upload turns the thumbnail into its retry - // control, except one the Host refused, which shows why and leaves removal as the way out. + // control, except one the Host refused, which explains why and leaves removal as the way out. + // The refusal is the only explanation of its warning, so a tap or click also pins it. const upload = uploads[attachment.id] const name = attachment.file.name || t('image.pending') const refusal = upload?.status === 'error' && upload.reason !== undefined @@ -89,28 +90,30 @@ export function ComposerAttachments({
{refusal !== undefined ? ( -
- - -
+ + + ) : upload?.status === 'error' ? ( - + + + ) : (