Merge remote-tracking branch 'origin/master' into worktree/code-diff-card
This commit is contained in:
commit
3c632ebf66
318 files changed
+9061
-3253
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
|
||||
README.md: c7d5e58cca77bfb182964a22139d6925bdc64501
|
||||
README.zh.md: fdfcf02f20cece5f015e438283b6cd4e426f66d9
|
||||
README.md: ea15a8700bf07fc640375b0d1ea4af18c7ff2199
|
||||
README.zh.md: f9b24dfd8e3b67a144bf724616d203cbf6e4dd63
|
||||
@@ -85,7 +85,7 @@ Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`; eq
|
||||
|
||||
Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion.
|
||||
|
||||
Request versions live below `<DSH_HOME>/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
|
||||
Request versions live below `<DSH_HOME>/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
|
||||
|
||||
Generic-file bytes have one canonical object at `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`. Each reference path at `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ kind: "package-reference"
|
||||
|
||||
准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。
|
||||
|
||||
请求版本位于由 `dshCachePath` 解析的 `<DSH_HOME>/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
|
||||
请求版本位于由 `dshCachePath` 解析的 `<DSH_HOME>/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
|
||||
|
||||
通用文件字节的唯一规范对象位于 `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`。每条引用路径 `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
FileAttachmentRef,
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
ImageRequestPolicy,
|
||||
ImageRequestTarget,
|
||||
RequestImageAttachment,
|
||||
SaveFileAttachment,
|
||||
SaveFileStreamAttachment,
|
||||
@@ -247,20 +247,20 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
|
||||
override async readImageRequest(
|
||||
ref: ImageAttachmentRef,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestImageAttachment> {
|
||||
return this.requestVersion(ref, policy, undefined, signal)
|
||||
return this.requestVersion(ref, target, undefined, signal)
|
||||
}
|
||||
|
||||
private requestVersion(
|
||||
ref: ImageAttachmentRef,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
stored: StoredImageAttachment | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<RequestImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
const variantId = requestImageVariantId(ref, policy)
|
||||
const variantId = requestImageVariantId(ref, target)
|
||||
const key = String(variantId)
|
||||
let operation = this.requestInflight.get(key)
|
||||
if (operation?.controller.signal.aborted) {
|
||||
@@ -272,7 +272,7 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
const request = await readRequestImageFile(
|
||||
this.cacheRoot,
|
||||
stored ?? await this.readImage(ref, sharedSignal),
|
||||
policy,
|
||||
target,
|
||||
sharedSignal,
|
||||
)
|
||||
return request
|
||||
|
||||
@@ -4,11 +4,11 @@ import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import sharp, { type Sharp } from 'sharp'
|
||||
import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageMediaType,
|
||||
ImageAttachmentRef,
|
||||
ImageRequestPolicy,
|
||||
ImageRequestTarget,
|
||||
RequestImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts'
|
||||
|
||||
/** Transform version included in every cache and upload-index identity. */
|
||||
export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5'
|
||||
export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v6'
|
||||
|
||||
interface EncodedRequestImage {
|
||||
data: Uint8Array
|
||||
@@ -46,17 +46,19 @@ function checkedInteger(value: number, name: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
function validatePolicy(policy: ImageRequestPolicy): void {
|
||||
checkedInteger(policy.maxPixels, 'Image request maxPixels')
|
||||
checkedInteger(policy.maxBytes, 'Image request maxBytes')
|
||||
function validateTarget(target: ImageRequestTarget): void {
|
||||
checkedInteger(target.width, 'Image request width')
|
||||
checkedInteger(target.height, 'Image request height')
|
||||
checkedInteger(target.maxBytes, 'Image request maxBytes')
|
||||
}
|
||||
|
||||
function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string {
|
||||
function descriptor(attachment: ImageAttachmentRef, target: ImageRequestTarget): string {
|
||||
return JSON.stringify({
|
||||
transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION,
|
||||
attachmentId: attachment.attachmentId,
|
||||
routePixelBudget: policy.maxPixels,
|
||||
encodedByteBudget: policy.maxBytes,
|
||||
targetWidth: target.width,
|
||||
targetHeight: target.height,
|
||||
encodedByteBudget: target.maxBytes,
|
||||
encoding: {
|
||||
webpQualities: IMAGE_ENCODING_QUALITIES,
|
||||
webpEffort: WEBP_ENCODING_EFFORT,
|
||||
@@ -68,21 +70,23 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy):
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete deterministic identity for one attachment and route-owned request policy.
|
||||
* Complete deterministic identity for one attachment and route-chosen request target.
|
||||
* @param attachment - provider-independent durable normalized attachment reference.
|
||||
* @param policy - route-owned pixel and byte policy.
|
||||
* @param target - route-chosen dimensions and byte target.
|
||||
* @returns branded digest over every request transform input.
|
||||
*/
|
||||
export function requestImageVariantId(
|
||||
attachment: ImageAttachmentRef,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
): ReturnType<typeof ImageVariantId> {
|
||||
return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`)
|
||||
return ImageVariantId(`sha256:${digest(descriptor(attachment, target))}`)
|
||||
}
|
||||
|
||||
function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp {
|
||||
/** Resize by the source long edge only, so the encoder derives the short edge as the route predicts. */
|
||||
function pipeline(attachment: StoredImageAttachment, target: ImageRequestTarget): Sharp {
|
||||
const byWidth = attachment.ref.width >= attachment.ref.height
|
||||
return sourcePipeline(attachment)
|
||||
.resize({ width, height, fit: 'inside', withoutEnlargement: true })
|
||||
.resize({ ...byWidth ? { width: target.width } : { height: target.height }, withoutEnlargement: true })
|
||||
}
|
||||
|
||||
function sourcePipeline(attachment: StoredImageAttachment): Sharp {
|
||||
@@ -91,13 +95,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp {
|
||||
|
||||
async function createRequestImage(
|
||||
attachment: StoredImageAttachment,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
hasAlpha: boolean,
|
||||
): Promise<EncodedRequestImage> {
|
||||
const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels)
|
||||
if (dimensions.width === attachment.ref.width
|
||||
&& dimensions.height === attachment.ref.height
|
||||
&& attachment.data.byteLength <= policy.maxBytes) {
|
||||
if (target.width >= attachment.ref.width
|
||||
&& target.height >= attachment.ref.height
|
||||
&& attachment.data.byteLength <= target.maxBytes) {
|
||||
return {
|
||||
data: attachment.data,
|
||||
mediaType: attachment.ref.mediaType,
|
||||
@@ -106,8 +109,8 @@ async function createRequestImage(
|
||||
}
|
||||
}
|
||||
const encodedVersion = await encodeFirstWithinLimit(
|
||||
encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha),
|
||||
policy.maxBytes,
|
||||
encodingLadder(pipeline(attachment, target), hasAlpha),
|
||||
target.maxBytes,
|
||||
)
|
||||
return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion
|
||||
}
|
||||
@@ -118,17 +121,15 @@ function cachePath(root: string, hash: string): string {
|
||||
|
||||
async function readCached(
|
||||
path: string,
|
||||
attachment: StoredImageAttachment,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
expectedAlpha: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<VerifiedRequestImage | undefined> {
|
||||
try {
|
||||
const data = new Uint8Array(await readFile(path, { signal }))
|
||||
const detected = await probeImage(data)
|
||||
const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels)
|
||||
if (detected.depth !== 'uchar' || detected.space !== 'srgb'
|
||||
|| detected.width > maximum.width || detected.height > maximum.height
|
||||
|| detected.width > target.width || detected.height > target.height
|
||||
|| !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined
|
||||
return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha }
|
||||
} catch (error: unknown) {
|
||||
@@ -169,24 +170,24 @@ async function writeCached(path: string, data: Uint8Array): Promise<void> {
|
||||
* Generate or reuse one request image below the local attachment cache root.
|
||||
* @param root - absolute attachment cache root; variants use its `request-images` child.
|
||||
* @param attachment - verified normalized attachment bytes and reference.
|
||||
* @param policy - exact route request-image policy.
|
||||
* @param target - exact route-chosen dimensions and byte target; a target above the source keeps the source size.
|
||||
* @param signal - optional cancellation for cache I/O and image transformation.
|
||||
* @returns verified request bytes and deterministic variant identity.
|
||||
*/
|
||||
export async function readRequestImageFile(
|
||||
root: string,
|
||||
attachment: StoredImageAttachment,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
validatePolicy(policy)
|
||||
validateTarget(target)
|
||||
const source = await probeImage(attachment.data)
|
||||
const variantId = requestImageVariantId(attachment.ref, policy)
|
||||
const variantId = requestImageVariantId(attachment.ref, target)
|
||||
const hash = String(variantId).slice('sha256:'.length)
|
||||
const path = cachePath(root, hash)
|
||||
const cached = await readCached(path, attachment, policy, source.hasAlpha, signal)
|
||||
const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha)
|
||||
const cached = await readCached(path, target, source.hasAlpha, signal)
|
||||
const created = cached ?? await createRequestImage(attachment, target, source.hasAlpha)
|
||||
const version = cached ?? (created.data === attachment.data
|
||||
? { ...created, hasAlpha: source.hasAlpha }
|
||||
: await verifyRequestImage(created, source.hasAlpha))
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('local attachment service', () => {
|
||||
String(ref.attachmentId).slice('sha256:'.length),
|
||||
))
|
||||
await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data))
|
||||
const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 })
|
||||
const request = await service.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1024 })
|
||||
expect(request).not.toHaveProperty('access')
|
||||
|
||||
const fileData = Uint8Array.of(0, 1, 2, 255)
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('request image verification', () => {
|
||||
const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
|
||||
control.mismatch = true
|
||||
|
||||
await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }))
|
||||
await expect(attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }))
|
||||
.rejects.toMatchObject({
|
||||
code: 'ATTACHMENT_WRITE_FAILED',
|
||||
message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.',
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('local request-image cache', () => {
|
||||
const stored = await attachments.readImage(attachment)
|
||||
const fileData = Uint8Array.of(0, 1, 2, 255)
|
||||
const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' })
|
||||
const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
|
||||
const policy = { width: 22, height: 11, maxBytes: 4_096 }
|
||||
const initial = await attachments.readImageRequest(attachment, policy)
|
||||
const hash = String(initial.variantId).slice('sha256:'.length)
|
||||
const cacheRoot = join(dshHome, 'cache')
|
||||
@@ -86,7 +86,7 @@ describe('local request-image cache', () => {
|
||||
const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
|
||||
const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })
|
||||
const firstStored = await attachments.readImage(first)
|
||||
const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 }
|
||||
const policy = { width: 8, height: 8, maxBytes: 1024 * 1024 }
|
||||
|
||||
const request = await attachments.readImageRequest(first, policy)
|
||||
const batch = await Promise.all([first, second].map(
|
||||
@@ -97,21 +97,61 @@ describe('local request-image cache', () => {
|
||||
expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId])
|
||||
})
|
||||
|
||||
it('rejects invalid request policies', async () => {
|
||||
it('rejects invalid request targets', async () => {
|
||||
const attachments = await store()
|
||||
const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
|
||||
|
||||
await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 }))
|
||||
.rejects.toThrow('Image request maxPixels must be a positive integer')
|
||||
await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 }))
|
||||
await expect(attachments.readImageRequest(attachment, { width: 0, height: 4, maxBytes: 100 }))
|
||||
.rejects.toThrow('Image request width must be a positive integer')
|
||||
await expect(attachments.readImageRequest(attachment, { width: 8, height: 1.5, maxBytes: 100 }))
|
||||
.rejects.toThrow('Image request height must be a positive integer')
|
||||
await expect(attachments.readImageRequest(attachment, { width: 8, height: 4, maxBytes: 0 }))
|
||||
.rejects.toThrow('Image request maxBytes must be a positive integer')
|
||||
})
|
||||
|
||||
it('resizes by the long edge to the exact target and keys the cache by target', async () => {
|
||||
const attachments = await store()
|
||||
const maxBytes = 2 * 1024 * 1024
|
||||
const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' })
|
||||
const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' })
|
||||
const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' })
|
||||
const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' })
|
||||
const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' })
|
||||
|
||||
const squareRequest = await attachments.readImageRequest(square, { width: 1302, height: 1302, maxBytes })
|
||||
const smallRequest = await attachments.readImageRequest(small, { width: 800, height: 800, maxBytes })
|
||||
const thinRequest = await attachments.readImageRequest(thin, { width: 4096, height: 20, maxBytes })
|
||||
const wideRequest = await attachments.readImageRequest(wide, { width: 1708, height: 961, maxBytes })
|
||||
const tallRequest = await attachments.readImageRequest(tall, { width: 961, height: 1708, maxBytes })
|
||||
const smaller = await attachments.readImageRequest(square, { width: 1024, height: 1024, maxBytes })
|
||||
const enlarged = await attachments.readImageRequest(thin, { width: 9000, height: 45, maxBytes })
|
||||
|
||||
expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' })
|
||||
expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' })
|
||||
expect(smallRequest.data).toEqual((await attachments.readImage(small)).data)
|
||||
expect(thinRequest).toMatchObject({ width: 4096, height: 20 })
|
||||
expect(wideRequest).toMatchObject({ width: 1708, height: 961 })
|
||||
expect(tallRequest).toMatchObject({ width: 961, height: 1708 })
|
||||
expect(enlarged).toMatchObject({ width: 8000, height: 40 })
|
||||
expect(smaller.variantId).not.toBe(squareRequest.variantId)
|
||||
expect(enlarged.variantId).not.toBe(thinRequest.variantId)
|
||||
})
|
||||
|
||||
it('encodes the rounded short edge at the route target', async () => {
|
||||
const attachments = await store()
|
||||
const attachment = await attachments.saveImage({ data: await image(1224, 1429), mediaType: 'image/png' })
|
||||
const request = await attachments.readImageRequest(attachment, {
|
||||
width: 1187, height: 1386, maxBytes: 2 * 1024 * 1024,
|
||||
})
|
||||
expect(request).toMatchObject({ width: 1187, height: 1386 })
|
||||
await expect(sharp(request.data).metadata()).resolves.toMatchObject({ width: 1187, height: 1386 })
|
||||
})
|
||||
|
||||
it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => {
|
||||
const attachments = await store()
|
||||
const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })
|
||||
|
||||
const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })
|
||||
const request = await attachments.readImageRequest(attachment, { width: 1, height: 1, maxBytes: 1 })
|
||||
|
||||
expect(request.mediaType).toBe('image/jpeg')
|
||||
expect(request.bytes).toBeGreaterThan(1)
|
||||
@@ -122,7 +162,7 @@ describe('local request-image cache', () => {
|
||||
const dshHome = await home()
|
||||
const attachments = new LocalAttachmentStore(new Context(), { dshHome })
|
||||
const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
|
||||
const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
|
||||
const policy = { width: 22, height: 11, maxBytes: 4_096 }
|
||||
const initial = await attachments.readImageRequest(attachment, policy)
|
||||
const hash = String(initial.variantId).slice('sha256:'.length)
|
||||
const path = join(dshHome, 'cache', 'attachments', 'request-images', hash.slice(0, 2), hash)
|
||||
@@ -171,10 +211,10 @@ describe('local request-image cache', () => {
|
||||
data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png',
|
||||
})
|
||||
|
||||
const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
|
||||
const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
|
||||
const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
|
||||
const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 })
|
||||
const squareRequest = await attachments.readImageRequest(square, { width: 800, height: 800, maxBytes: 1024 * 1024 })
|
||||
const wideRequest = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 })
|
||||
const repeated = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 })
|
||||
const low = await attachments.readImageRequest(wide, { width: 724, height: 362, maxBytes: 1024 * 1024 })
|
||||
|
||||
expect(squareRequest).toMatchObject({ width: 800, height: 800 })
|
||||
expect(wideRequest).toMatchObject({ width: 1130, height: 565 })
|
||||
@@ -214,8 +254,8 @@ describe('local request-image cache', () => {
|
||||
const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })
|
||||
const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })
|
||||
|
||||
const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 })
|
||||
const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 })
|
||||
const photoRequest = await attachments.readImageRequest(photo, { width: 128, height: 128, maxBytes: 1024 * 1024 })
|
||||
const alphaRequest = await attachments.readImageRequest(alpha, { width: 128, height: 128, maxBytes: 4_096 })
|
||||
|
||||
expect(photoRequest.mediaType).toBe('image/jpeg')
|
||||
expect(alphaRequest.mediaType).toBe('image/webp')
|
||||
@@ -231,7 +271,7 @@ describe('local request-image cache', () => {
|
||||
}).toColourspace('rgb16').png().toBuffer())
|
||||
const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
|
||||
|
||||
const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
|
||||
const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })
|
||||
|
||||
expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
|
||||
expect(request.width * request.height).toBeLessThanOrEqual(16 * 16)
|
||||
@@ -245,7 +285,7 @@ describe('local request-image cache', () => {
|
||||
const source = await complexOpaqueAlphaImage(64, 32)
|
||||
const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
|
||||
|
||||
const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
|
||||
const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })
|
||||
|
||||
expect(request.mediaType).toBe('image/webp')
|
||||
await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false })
|
||||
@@ -267,7 +307,7 @@ describe('local request-image cache', () => {
|
||||
}).png().toBuffer())
|
||||
const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
|
||||
|
||||
const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
|
||||
const request = await attachments.readImageRequest(attachment, { width: 800, height: 800, maxBytes: 1024 * 1024 })
|
||||
|
||||
expect(request).toMatchObject({ width: 800, height: 800 })
|
||||
expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
|
||||
@@ -280,7 +320,7 @@ describe('local request-image cache', () => {
|
||||
})
|
||||
const run = vi.spyOn(CompressionLimiter.prototype, 'run')
|
||||
const controller = new AbortController()
|
||||
const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
|
||||
const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 }
|
||||
|
||||
const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
|
||||
const completed = attachments.readImageRequest(attachment, policy)
|
||||
@@ -310,7 +350,7 @@ describe('local request-image cache', () => {
|
||||
const controller = new AbortController()
|
||||
const request = attachments.readImageRequest(
|
||||
attachment,
|
||||
{ maxPixels: 640_000, maxBytes: 1024 * 1024 },
|
||||
{ width: 1130, height: 565, maxBytes: 1024 * 1024 },
|
||||
controller.signal,
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
@@ -343,7 +383,7 @@ describe('local request-image cache', () => {
|
||||
return actualRead(ref, signal)
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
|
||||
const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 }
|
||||
const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
|
||||
await vi.waitFor(() => {
|
||||
expect(calls).toBe(1)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
|
||||
README.md: 35fde71833fa13b26662244cfa68cec17b90b663
|
||||
README.zh.md: b0e98d1e4a0c35082a8eef23f69659f47916d3f7
|
||||
README.md: d2a667e2d12deadc5e82a83cca6565f0b5a6aae0
|
||||
README.zh.md: b5598eb8ccca39d8b5be346c585fd84d3dd38468
|
||||
@@ -66,13 +66,13 @@ This section explains the design decisions behind the seam and the service opera
|
||||
- **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization.
|
||||
- **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
- **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed.
|
||||
- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path.
|
||||
- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path.
|
||||
- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain.
|
||||
- **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route.
|
||||
|
||||
### 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, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, 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 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).
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -66,13 +66,13 @@ kind: "package-reference"
|
||||
- **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。
|
||||
- **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。
|
||||
- **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。
|
||||
- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。
|
||||
- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。
|
||||
- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。
|
||||
- **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。
|
||||
|
||||
### 服务操作
|
||||
|
||||
服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`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)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
FileAttachmentRef,
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
ImageRequestPolicy,
|
||||
ImageRequestTarget,
|
||||
RequestImageAttachment,
|
||||
SaveFileAttachment,
|
||||
SaveFileStreamAttachment,
|
||||
@@ -22,7 +22,8 @@ export { AttachmentId, ImageVariantId } from './brand.ts'
|
||||
export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts'
|
||||
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
|
||||
export { admitEncodedFile, admitEncodedImages } from './admission.ts'
|
||||
export { requestImageDimensions } from './request-projection.ts'
|
||||
export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts'
|
||||
export type { ProjectedDimensions } from './request-projection.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
AdmittedPromptContentPart,
|
||||
@@ -32,7 +33,7 @@ export type {
|
||||
FileAttachmentRef,
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
ImageRequestPolicy,
|
||||
ImageRequestTarget,
|
||||
ImageMediaType,
|
||||
PromptContentPart,
|
||||
RequestImageAttachment,
|
||||
@@ -241,18 +242,18 @@ export abstract class AttachmentStore extends Service {
|
||||
/**
|
||||
* Generate or read one deterministic model-request version from the stored normalized image.
|
||||
* @param ref - durable provider-independent normalized attachment reference.
|
||||
* @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.
|
||||
* @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
|
||||
* @param signal - optional cancellation.
|
||||
* @returns request bytes and the cache/upload identity covering every transform input.
|
||||
*/
|
||||
readImageRequest(
|
||||
ref: ImageAttachmentRef,
|
||||
policy: ImageRequestPolicy,
|
||||
target: ImageRequestTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
void ref
|
||||
void policy
|
||||
void target
|
||||
return Promise.reject(new AttachmentError(
|
||||
'The mounted attachment provider cannot derive model-request images.',
|
||||
'ATTACHMENT_PROJECTION_UNSUPPORTED',
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Pure request-projection geometry shared by attachment providers and
|
||||
* provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection
|
||||
* Pure request-projection geometry shared by model routes and provider-side
|
||||
* request pricing. @module @deepseek-ai/dsh-attachment/request-projection
|
||||
*/
|
||||
|
||||
/** Integer width and height of one projected image. */
|
||||
export interface ProjectedDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute aspect-preserving integer dimensions within a hard total-pixel budget.
|
||||
* @param width - positive source width.
|
||||
@@ -14,7 +20,7 @@ export function requestImageDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
maxPixels: number,
|
||||
): { width: number; height: number } {
|
||||
): ProjectedDimensions {
|
||||
const scale = Math.min(1, Math.sqrt(maxPixels / (width * height)))
|
||||
if (scale === 1) return { width, height }
|
||||
if (width >= height) {
|
||||
@@ -34,3 +40,23 @@ export function requestImageDimensions(
|
||||
}
|
||||
return { width: projectedWidth, height: projectedHeight }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute aspect-preserving integer dimensions with an exact long edge; the
|
||||
* short edge rounds to the nearest pixel, as an encoder resize by the
|
||||
* long edge alone.
|
||||
* @param width - positive source width.
|
||||
* @param height - positive source height.
|
||||
* @param longEdge - positive target for the longer source edge.
|
||||
* @returns the target dimensions; a long edge at or above the source returns the source unchanged.
|
||||
*/
|
||||
export function longEdgeDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
longEdge: number,
|
||||
): ProjectedDimensions {
|
||||
if (longEdge >= Math.max(width, height)) return { width, height }
|
||||
return width >= height
|
||||
? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) }
|
||||
: { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge }
|
||||
}
|
||||
@@ -132,10 +132,12 @@ export interface StoredImageAttachment {
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
/** Deterministic request-image policy selected by one exact model route. */
|
||||
export interface ImageRequestPolicy {
|
||||
/** Maximum width multiplied by height after aspect-preserving projection. */
|
||||
maxPixels: number
|
||||
/** Deterministic request-image target selected by one exact model route for one attachment. */
|
||||
export interface ImageRequestTarget {
|
||||
/** Target width in pixels; a target above the source keeps the source width. */
|
||||
width: number
|
||||
/** Target height in pixels; a target above the source keeps the source height. */
|
||||
height: number
|
||||
/** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import AttachmentStore, {
|
||||
isImageAdmissionError,
|
||||
type ImageAttachmentRef,
|
||||
type ImageMediaType,
|
||||
type ImageRequestPolicy,
|
||||
type ImageRequestTarget,
|
||||
type RequestImageAttachment,
|
||||
type SaveFileAttachment,
|
||||
type SaveImageAttachment,
|
||||
@@ -56,7 +56,7 @@ class RecordingStore extends AttachmentStore {
|
||||
|
||||
override readImageRequest(
|
||||
ref: ImageAttachmentRef,
|
||||
_policy: ImageRequestPolicy,
|
||||
_target: ImageRequestTarget,
|
||||
): Promise<RequestImageAttachment> {
|
||||
this.calls.push(`request:${ref.name}`)
|
||||
return Promise.resolve({
|
||||
@@ -154,12 +154,12 @@ describe('AttachmentStore.readImageRequest', () => {
|
||||
it('reports unsupported request projection while preserving cancellation', async () => {
|
||||
const store = new UnsupportedProjectionStore(new Context())
|
||||
const ref = await new RecordingStore(new Context()).saveImage(image(1))
|
||||
await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }))
|
||||
await expect(store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 }))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' })
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel unsupported projection')
|
||||
controller.abort(reason)
|
||||
expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason)
|
||||
expect(() => store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 }, controller.signal)).toThrow(reason)
|
||||
})
|
||||
|
||||
it('rejects generic-file storage and exposes no provider-owned host path by default', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { requestImageDimensions } from '../src/index.ts'
|
||||
import { longEdgeDimensions, requestImageDimensions } from '../src/index.ts'
|
||||
|
||||
describe('request image dimensions', () => {
|
||||
it.each([
|
||||
@@ -27,3 +27,17 @@ describe('request image dimensions', () => {
|
||||
expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('long-edge dimensions', () => {
|
||||
it('keeps the long edge exact and rounds the short edge', () => {
|
||||
expect(longEdgeDimensions(8000, 40, 4096)).toEqual({ width: 4096, height: 20 })
|
||||
expect(longEdgeDimensions(10_000, 100, 4096)).toEqual({ width: 4096, height: 41 })
|
||||
expect(longEdgeDimensions(1080, 2400, 1862)).toEqual({ width: 838, height: 1862 })
|
||||
expect(longEdgeDimensions(1, 9000, 4096)).toEqual({ width: 1, height: 4096 })
|
||||
})
|
||||
|
||||
it('never enlarges a source at or below the long edge', () => {
|
||||
expect(longEdgeDimensions(320, 240, 320)).toEqual({ width: 320, height: 240 })
|
||||
expect(longEdgeDimensions(320, 240, 4096)).toEqual({ width: 320, height: 240 })
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: a525440f20879314bd8d12b647830f2c541f59b3
|
||||
README.zh.md: 90aa0beda52c2968c93eab13bae11640e81e0664
|
||||
README.md: 10d582289707c87bd84b0eb32267ac4f5addf884
|
||||
README.zh.md: c8bb61e28e8ad758c757fe11a5b55135e1daf319
|
||||
@@ -40,7 +40,7 @@ installFailLoud('dsh')
|
||||
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
|
||||
```
|
||||
|
||||
With that entry point, success looks like a running app with every plugin active; failure is never silent — one labelled line names the failing plugin and the stage, and the process exits nonzero. The app context is torn down before the error is reported, so nothing keeps running half-started.
|
||||
With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
|
||||
|
||||
<a id="profiles"></a>
|
||||
### Profiles
|
||||
@@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home:
|
||||
- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
|
||||
- **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
|
||||
|
||||
Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
|
||||
Profiles with `patchReload: live` watch both user patch files and apply the [reload failure policy](#startup-and-reload-failures). A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
|
||||
|
||||
Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
|
||||
|
||||
@@ -62,9 +62,28 @@ Inserted plugin names may be absolute filesystem paths, file URLs, or package sp
|
||||
|
||||
Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
|
||||
|
||||
### What you see when startup fails
|
||||
<a id="startup-and-reload-failures"></a>
|
||||
### Startup and reload failures
|
||||
|
||||
Startup failure is a single labelled line plus a nonzero exit — never a silent hang or a raw stack dump. The message names the failing plugin; a plugin that threw keeps its original error, and an entry that never started is reported with the services it was waiting for.
|
||||
After the Loader settles, app-boot reports optional failures as warnings and rejects startup if an enabled required entry cannot activate. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
|
||||
|
||||
| Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR |
|
||||
|---|---|---|---|
|
||||
| Root config or required overlay is missing, unreadable, malformed, or contains invalid entries | Stop startup | Stop startup | Malformed or invalid live patches are rejected without changing the running configuration; a valid edit applies |
|
||||
| Module import fails or module evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a corrected import can activate |
|
||||
| Plugin config schema validation fails | Warn; continue | Stop startup | A new entry stays inactive; an existing entry retains its prior instance and config; a valid correction applies |
|
||||
| Config `!!js` evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a valid correction can activate |
|
||||
| `disabled: !!js` evaluation throws | Warn; continue | Stop startup | Report the evaluation error rather than treating the entry as disabled; a valid correction can activate |
|
||||
| Synchronous `apply()` throws | Warn; continue | Stop startup | Report the error; keep successful siblings; corrected config can activate |
|
||||
| Asynchronous `apply()` throws | Warn after settlement; continue | Stop startup after settlement | Report the error after settlement; keep successful siblings; corrected config can activate |
|
||||
| An injected service is unavailable | Warn; continue while the entry waits for its dependencies | Stop startup | Keep the entry waiting; adding the missing provider can activate it |
|
||||
| HTTP port binding fails | Warn; continue without that endpoint | Stop startup | Keep the process running without the failed endpoint; corrected config can restore it |
|
||||
| Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id |
|
||||
| Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit |
|
||||
|
||||
The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes.
|
||||
|
||||
The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) and [startup acceptance](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) verify these outcomes through the shipped Web profile; [app-boot tests](tests/app-boot.spec.ts) also exercise root Include failures.
|
||||
|
||||
If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it.
|
||||
|
||||
@@ -86,9 +105,10 @@ This section explains how the outcomes above are realized and points at the code
|
||||
|
||||
- **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
|
||||
- **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
|
||||
- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
|
||||
- **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
|
||||
- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
|
||||
- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
|
||||
- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
|
||||
- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. Cyclic causes terminate diagnostic traversal without replacing the original cause.
|
||||
|
||||
### Helper behavior
|
||||
|
||||
@@ -118,7 +138,7 @@ Read these pages when the package-level contract is not enough. They move from t
|
||||
- [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`).
|
||||
- [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior.
|
||||
- [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design.
|
||||
- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of transaction behavior and native filesystem delivery.
|
||||
- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of live-patch behavior and native filesystem delivery.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ installFailLoud('dsh')
|
||||
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
|
||||
```
|
||||
|
||||
有了这个入口,成功就是每个插件都已激活的运行中应用;失败绝不会悄无声息——一行带标签的信息点名失败的插件与阶段,进程以非零码退出。错误上报前会先拆卸应用上下文,因此不会留下半启动的残留。
|
||||
有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
|
||||
|
||||
<a id="profiles"></a>
|
||||
### Profile
|
||||
@@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
|
||||
- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
|
||||
- **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
|
||||
|
||||
带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
|
||||
带 `patchReload: live` 的 profile 会监视两份用户 patch 文件,并应用[重载失败策略](#startup-and-reload-failures)。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
|
||||
|
||||
插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
|
||||
|
||||
@@ -62,9 +62,28 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
|
||||
|
||||
启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
|
||||
|
||||
### 启动失败时你会看到什么
|
||||
<a id="startup-and-reload-failures"></a>
|
||||
### 启动与重载失败
|
||||
|
||||
启动失败是一行带标签的信息加非零退出码——绝不是静默卡死或原始堆栈转储。信息会点名失败的插件;抛错的插件保留原始错误,从未启动的条目会连同它等待的服务一起报告。
|
||||
Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的 required 条目无法激活,则拒绝启动。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
|
||||
|
||||
| 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR |
|
||||
|---|---|---|---|
|
||||
| 根配置或必需 overlay 缺失、不可读、格式错误,或包含无效条目 | 终止启动 | 终止启动 | 拒绝格式错误或无效的实时 patch,不改变运行中的配置;有效修改可以应用 |
|
||||
| 模块 import 失败或模块求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正 import 后可以激活 |
|
||||
| 插件配置 schema 校验失败 | 警告;继续 | 终止启动 | 新条目保持未激活;现有条目保留原实例与配置;有效修正可以应用 |
|
||||
| 配置 `!!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;有效修正后可以激活 |
|
||||
| `disabled: !!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告求值错误,不将条目当作已禁用;有效修正后可以激活 |
|
||||
| 同步 `apply()` throw | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正配置后可以激活 |
|
||||
| 异步 `apply()` throw | 结算后警告;继续 | 结算后终止启动 | 结算后报告错误;保留成功的兄弟插件;修正配置后可以激活 |
|
||||
| 注入的服务不可用 | 警告;继续,条目等待依赖 | 终止启动 | 条目继续等待;补上缺失的提供方后可以激活 |
|
||||
| HTTP 端口绑定失败 | 警告;继续,但该端点不可用 | 终止启动 | 进程继续运行,但失败的端点不可用;修正配置后可以恢复 |
|
||||
| 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
|
||||
| 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 |
|
||||
|
||||
上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。
|
||||
|
||||
[Web 进程矩阵](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)和[启动验收测试](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)通过随附 Web profile 验证这些结果;[app-boot 测试](tests/app-boot.spec.ts)还覆盖根 Include 失败。
|
||||
|
||||
如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。
|
||||
|
||||
@@ -86,9 +105,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
|
||||
|
||||
- **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
|
||||
- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
|
||||
- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
|
||||
- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
|
||||
- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
|
||||
- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
|
||||
- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
|
||||
- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因。
|
||||
|
||||
### Helper 行为
|
||||
|
||||
@@ -118,7 +138,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
|
||||
- [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。
|
||||
- [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。
|
||||
- [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。
|
||||
- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——事务行为与原生文件系统投递的验证归属。
|
||||
- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——实时 patch 行为与原生文件系统投递的验证归属。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-package-manifest": "workspace:^",
|
||||
"chokidar": "4.0.3",
|
||||
"js-yaml": "^4.2.0",
|
||||
"resolve.exports": "^2.0.3"
|
||||
},
|
||||
@@ -57,7 +58,6 @@
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"chokidar": "4.0.3"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-hmr'
|
||||
import { watchConfig } from './watch-config.ts'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -241,7 +242,7 @@ export interface UserPatchWatchOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
|
||||
* Watch the user patch layer and reapply it to the boot Include without rollback.
|
||||
* @param ctx - settled app context containing the root Include and an active HMR service.
|
||||
* @param options - diagnostic, file, and patch-composition inputs.
|
||||
* @returns an asynchronous disposer after the exact-path watcher is ready.
|
||||
@@ -256,7 +257,7 @@ export async function watchUserPatches(
|
||||
if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
|
||||
const entry = bootstrapIncludes.get(ctx)
|
||||
if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
|
||||
const register = hmr.registerConfig(filename, async () => {
|
||||
const register = watchConfig(ctx, filename, hmr.config, async () => {
|
||||
// Re-read the include's non-patch options per refresh so a writer that
|
||||
// updates another option between refreshes is not silently reverted.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
@@ -268,6 +269,10 @@ export async function watchUserPatches(
|
||||
patches,
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await())))
|
||||
const failures = await inactiveEntries(ctx)
|
||||
if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
|
||||
})
|
||||
try {
|
||||
return await register
|
||||
@@ -511,7 +516,7 @@ function groupedDump(
|
||||
* names; relative names continue to resolve beside the configuration file.
|
||||
* @returns the created root Include entry, or `undefined` when a surface
|
||||
* disposed the whole tree (taking the Loader service with it) while the
|
||||
* transactional create was still settling entry lifecycle.
|
||||
* entry creation was in flight.
|
||||
*/
|
||||
export async function mountRootInclude(
|
||||
ctx: Context,
|
||||
@@ -610,7 +615,7 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
|
||||
/**
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
|
||||
* {@link assertEntriesActivated} is ignored during its process checkpoint;
|
||||
* {@link auditStartupEntries} is ignored during its process checkpoint;
|
||||
* every other rejection remains fatal. Stdout remains untouched for ACP; the
|
||||
* returned function removes the handler.
|
||||
*
|
||||
@@ -678,21 +683,6 @@ export function installFailLoud(
|
||||
return uninstall
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, reject entries with no fiber and name every plugin
|
||||
* whose module failed to resolve. Disabled entries are the only valid
|
||||
* fiber-less state.
|
||||
* @param ctx - the settled context whose loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
*/
|
||||
export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
||||
* Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
|
||||
@@ -702,30 +692,70 @@ const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
const FIBER_FAILED = 3 as FiberState.FAILED
|
||||
|
||||
/** Render a thrown plugin value without discarding an Error's original stack. */
|
||||
/**
|
||||
* Entry ids whose presence defines a usable DSH application.
|
||||
*
|
||||
* The list is global rather than profile metadata. Missing or disabled ids do
|
||||
* not affect startup; an enabled listed entry must activate. The list covers
|
||||
* shared Agent execution, application endpoints, and Web bootstrap/transport.
|
||||
*/
|
||||
const requiredStartupEntryIds = new Set<string>([
|
||||
'agent-loop',
|
||||
'webserver',
|
||||
'modules',
|
||||
'connection',
|
||||
'headless-runner',
|
||||
'acp',
|
||||
'sdk-jsonrpc-server',
|
||||
])
|
||||
|
||||
/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
|
||||
function formatActivationError(error: unknown): string {
|
||||
return error instanceof Error ? error.stack ?? error.message : String(error)
|
||||
const details: string[] = []
|
||||
const seen = new Set<Error>()
|
||||
function visit(value: unknown): void {
|
||||
if (!(value instanceof Error)) {
|
||||
details.push(String(value))
|
||||
return
|
||||
}
|
||||
if (seen.has(value)) return
|
||||
seen.add(value)
|
||||
details.push(value.stack ?? value.message)
|
||||
if (value.cause !== undefined) visit(value.cause)
|
||||
if (value instanceof AggregateError) value.errors.forEach(visit)
|
||||
}
|
||||
visit(error)
|
||||
return details.join('\n')
|
||||
}
|
||||
|
||||
interface InactiveEntry {
|
||||
/** Loader entry used to identify the bootstrap Include and required ids. */
|
||||
entry: Entry
|
||||
/** Complete diagnostic beginning with the entry id and module specifier. */
|
||||
diagnostic: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a settled Loader tree when an enabled entry failed or remains inactive.
|
||||
* Plugin failures include the original thrown stack; pending entries name their
|
||||
* unresolved services because no plugin error exists for that state. Active
|
||||
* entries require no further wait; only failed fibers are awaited to recover
|
||||
* their private rejection reason.
|
||||
* @param ctx - the settled context whose Loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @returns nothing when every enabled entry is active.
|
||||
* @throws after one process rejection checkpoint when an entry failed to
|
||||
* import, rejected during activation, or did not become active.
|
||||
* Collect Loader activation failures and disabled-expression errors. Failed
|
||||
* fibers are awaited to recover their recorded rejection reason and coalesce
|
||||
* duplicate Loader notifications through the next process rejection checkpoint.
|
||||
*/
|
||||
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
const failures: string[] = []
|
||||
async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
|
||||
const failures: InactiveEntry[] = []
|
||||
const rejectionReasons: unknown[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const subject = `${entry.options.id} (${entry.options.name})`
|
||||
try {
|
||||
if (entry.disabled) continue
|
||||
} catch (error) {
|
||||
failures.push({ entry, diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}` })
|
||||
continue
|
||||
}
|
||||
const fiber = entry.fiber
|
||||
if (fiber === undefined || entry.disabled) continue
|
||||
if (fiber === undefined) {
|
||||
failures.push({ entry, diagnostic: `${subject}: failed to import` })
|
||||
continue
|
||||
}
|
||||
const state = fiber.state
|
||||
if (state === FIBER_ACTIVE) continue
|
||||
if (state === FIBER_FAILED) {
|
||||
@@ -733,24 +763,65 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
rejectionReasons.push(error)
|
||||
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
|
||||
failures.push({ entry, diagnostic: `${subject}: ${formatActivationError(error)}` })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (state === FIBER_PENDING) {
|
||||
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
|
||||
const subject = missing.length === 1 ? 'service' : 'services'
|
||||
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
|
||||
failures.push({
|
||||
entry,
|
||||
diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
|
||||
})
|
||||
} else {
|
||||
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
|
||||
failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` })
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
if (rejectionReasons.length > 0) {
|
||||
await observeLoaderRejectionCheckpoint(rejectionReasons)
|
||||
}
|
||||
const noun = failures.length === 1 ? 'entry' : 'entries'
|
||||
throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
|
||||
if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
|
||||
return failures
|
||||
}
|
||||
|
||||
/** Render an inactive-entry diagnostic with a count and severity label. */
|
||||
function activationDiagnostic(
|
||||
binName: string,
|
||||
severity: 'warning' | 'required startup failure',
|
||||
failures: readonly InactiveEntry[],
|
||||
): string {
|
||||
const noun = failures.length === 1 ? 'entry' : 'entries'
|
||||
const prefix = binName === '' ? '' : `${binName}: `
|
||||
return `${prefix}${severity}: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => failure.diagnostic).join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply DSH startup policy to a settled Loader tree.
|
||||
*
|
||||
* Inactive entries from the global required list reject startup. Other
|
||||
* inactive entries produce one warning and leave successful siblings running.
|
||||
* Required ids absent from the tree, and disabled required entries, are ignored.
|
||||
* A throwing disabled expression is an entry failure, not a disabled entry.
|
||||
* The bootstrap Include must activate so unreadable or invalid root config is fatal.
|
||||
* @param ctx - the settled context whose Loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on optional-entry warnings.
|
||||
* @param warn - sink for optional-entry warnings.
|
||||
* @returns after optional warnings if required startup checks pass.
|
||||
* @throws when the bootstrap Include or a required entry is inactive or its disabled expression throws.
|
||||
*/
|
||||
export async function auditStartupEntries(
|
||||
ctx: Context,
|
||||
binName: string,
|
||||
warn: (line: string) => void = line => void process.stderr.write(line),
|
||||
): Promise<void> {
|
||||
const failures = await inactiveEntries(ctx)
|
||||
const required: InactiveEntry[] = []
|
||||
const optional: InactiveEntry[] = []
|
||||
for (const failure of failures) {
|
||||
const target = failure.entry === bootstrapIncludes.get(ctx)
|
||||
|| requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional
|
||||
target.push(failure)
|
||||
}
|
||||
if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional))
|
||||
if (required.length > 0) {
|
||||
throw new Error(activationDiagnostic('', 'required startup failure', required).trimEnd())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,12 +833,11 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* is statically imported and mounted as the `cordis:include` builtin, loading
|
||||
* through the ambient module pipeline (vite/tsx/plain ESM). The package build
|
||||
* embeds Include while leaving Loader external, so the built include tree and
|
||||
* host share one Loader peer. Loader
|
||||
* settlement rejects startup failures, which `boot` wraps after disposing the
|
||||
* partial context; a missing fiber or never-activating entry is rejected by
|
||||
* the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
|
||||
* init rejection with its original stack; later unhandled rejections remain
|
||||
* covered by {@link installFailLoud}. Built bins need the Loader's native
|
||||
* host share one Loader peer. Loader settlement drains entry work without
|
||||
* rejecting the whole tree. The final {@link auditStartupEntries} call rejects
|
||||
* failures in the global required list and warns about other failed, missing,
|
||||
* and pending entries while successful siblings remain active. Later unhandled
|
||||
* rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
|
||||
* helper for bare plugin specifiers; relative specifiers do not.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
@@ -778,11 +848,12 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* @param bareModuleBaseUrl - optional installed-host base for bare package
|
||||
* names; use it when the host, rather than the configuration project, owns the
|
||||
* complete plugin set.
|
||||
* @returns the root context once every entry has started, or as soon as a
|
||||
* @returns the root context after the initial startup audit, or as soon as a
|
||||
* surface disposed the tree while startup was still in flight.
|
||||
* @throws a labelled error after disposing the partial context — `host
|
||||
* preparation failed` when `prepare` threw before any config-tree entry
|
||||
* mounted, `plugin tree failed to load` afterwards.
|
||||
* mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
|
||||
* diagnostic traversal without replacing the original cause.
|
||||
*/
|
||||
export async function boot(
|
||||
binName: string,
|
||||
@@ -798,6 +869,11 @@ export async function boot(
|
||||
try {
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
// Fiber.update() discards the restart promise. Observe it before the
|
||||
// waterfall returns; activation audits still report the failed fiber.
|
||||
ctx.on('internal/update', (_config, _noSave, next: () => unknown) => {
|
||||
void Promise.resolve(next()).catch((error: unknown) => { ctx.logger.error(error) })
|
||||
}, { global: true, prepend: true })
|
||||
await ctx.plugin(Loader)
|
||||
await prepare?.(ctx)
|
||||
stage = 'plugin tree failed to load'
|
||||
@@ -806,12 +882,10 @@ export async function boot(
|
||||
// in flight, before the last entry settles. The Loader service goes with
|
||||
// it, and the activation audit describes a live tree — reading `ctx.loader`
|
||||
// past this point would throw a TypeError over an app that exited exactly
|
||||
// as asked. Transactional group updates settle
|
||||
// lifecycle inside the mount, so the teardown can land before it returns;
|
||||
// re-check after every await.
|
||||
// as asked. Re-check after settlement before auditing the tree.
|
||||
await ctx.get('loader')?.await()
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
await auditStartupEntries(ctx, binName)
|
||||
return ctx
|
||||
} catch (cause) {
|
||||
// Root-fiber disposal contains cleanup failures per observer (Cordis
|
||||
@@ -819,13 +893,13 @@ export async function boot(
|
||||
// result, so this await cannot reject and replace `cause`.
|
||||
await ctx.fiber.dispose()
|
||||
const detail = cause instanceof Error ? cause.message : String(cause)
|
||||
// The transactional Loader wraps a failing entry apply in one message per
|
||||
// tree layer; every layer's message is folded into `detail` above, and the
|
||||
// deepest cause is the plugin's own thrown error, whose stack names the
|
||||
// real failure site — append it so the startup diagnostic preserves the
|
||||
// original activation error instead of only the wrap chain.
|
||||
// A wrapper can carry an activation error whose original stack names the failed plugin.
|
||||
let deepest: unknown = cause
|
||||
while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
|
||||
const seen = new Set<Error>()
|
||||
while (deepest instanceof Error && !seen.has(deepest) && deepest.cause !== undefined) {
|
||||
seen.add(deepest)
|
||||
deepest = deepest.cause
|
||||
}
|
||||
const stack = deepest instanceof AggregateError
|
||||
? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
|
||||
: deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Exact-path watching for live profile patch files outside Cordis module roots. */
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { watch, type ChokidarOptions } from 'chokidar'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
const registrations = new WeakMap<Context, Set<string>>()
|
||||
|
||||
async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
|
||||
let root = dirname(filename)
|
||||
let depth = 0
|
||||
while (true) {
|
||||
try {
|
||||
if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
|
||||
const canonicalRoot = await realpath(root)
|
||||
return { filename: resolve(canonicalRoot, relative(root, filename)), root: canonicalRoot, depth }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
const parent = dirname(root)
|
||||
if (parent === root) throw error
|
||||
root = parent
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch one patch path, including missing parents, and serialize refresh callbacks.
|
||||
* @param ctx Context that owns watcher disposal and receives refresh failures.
|
||||
* @param filename Absolute patch-file path.
|
||||
* @param options Deployment watcher options inherited from the HMR configuration.
|
||||
* @param refresh Callback for additions, changes, and removals.
|
||||
* @returns A disposer that closes the watcher and drains its current refresh.
|
||||
* @throws When path resolution, watcher startup, or effect registration fails.
|
||||
*/
|
||||
export async function watchConfig(
|
||||
ctx: Context, filename: string, options: ChokidarOptions, refresh: () => Promise<void> | void,
|
||||
): Promise<() => Promise<void>> {
|
||||
const target = await findWatchRoot(filename)
|
||||
const paths = registrations.get(ctx) ?? new Set<string>()
|
||||
registrations.set(ctx, paths)
|
||||
if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`)
|
||||
const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options
|
||||
const watcher = watch(target.root, {
|
||||
...watchOptions, depth: target.depth, ignoreInitial: false,
|
||||
})
|
||||
paths.add(target.filename)
|
||||
const state = { dirty: false }
|
||||
let running: Promise<void> | undefined
|
||||
const onChange = (path: string) => {
|
||||
const observed = resolve(path)
|
||||
if (observed !== filename && observed !== target.filename) return
|
||||
state.dirty = true
|
||||
if (running) return
|
||||
running = (async () => {
|
||||
while (state.dirty) {
|
||||
state.dirty = false
|
||||
try {
|
||||
await refresh()
|
||||
} catch (reason) {
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
|
||||
ctx.logger.warn('config reload at %C failed', filename)
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
})().finally(() => { running = undefined })
|
||||
}
|
||||
watcher.on('add', onChange)
|
||||
watcher.on('change', onChange)
|
||||
watcher.on('unlink', onChange)
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let pending = true
|
||||
watcher.once('ready', () => { pending = false; ready.resolve() })
|
||||
watcher.on('error', (error) => {
|
||||
if (pending) { pending = false; ready.reject(error) } else { ctx.logger.warn(error) }
|
||||
})
|
||||
const dispose = async () => {
|
||||
await watcher.close()
|
||||
paths.delete(target.filename)
|
||||
await running
|
||||
}
|
||||
try {
|
||||
await ready.promise
|
||||
return ctx.effect(() => dispose, 'app-boot.watchConfig()')
|
||||
} catch (error) {
|
||||
await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
|
||||
addHarnessSourceSection, auditStartupEntries, boot,
|
||||
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
@@ -419,10 +419,11 @@ describe('installFailLoud', () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('assembled activation failure')
|
||||
const audit = assertEntriesActivated({
|
||||
const warn = vi.fn()
|
||||
const audit = auditStartupEntries({
|
||||
loader: {
|
||||
entries: () => ['broken-a', 'broken-b'].map(name => ({
|
||||
options: { name },
|
||||
options: { id: name, name },
|
||||
fiber: {
|
||||
state: 3,
|
||||
inject: {},
|
||||
@@ -431,13 +432,14 @@ describe('installFailLoud', () => {
|
||||
},
|
||||
})),
|
||||
},
|
||||
} as unknown as Context, NAME)
|
||||
} as unknown as Context, NAME, warn)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.written).toEqual([])
|
||||
expect(proc.exits).toEqual([])
|
||||
await expect(audit).rejects.toThrow('assembled activation failure')
|
||||
await audit
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure'))
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
@@ -501,36 +503,30 @@ describe('installFailLoud', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
|
||||
({ loader: { entries: () => entries } }) as unknown as Context
|
||||
describe('auditStartupEntries', () => {
|
||||
const requiredIds = [
|
||||
'agent-loop',
|
||||
'webserver',
|
||||
'modules',
|
||||
'connection',
|
||||
'headless-runner',
|
||||
'acp',
|
||||
'sdk-jsonrpc-server',
|
||||
]
|
||||
|
||||
it('passes when every enabled entry has a fiber', () => {
|
||||
expect(() => { assertEntriesLoaded(ctxWith([
|
||||
{ fiber: {}, options: { name: 'a' } },
|
||||
{ disabled: true, options: { name: 'off' } },
|
||||
]), NAME) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws naming every enabled fiber-less entry', () => {
|
||||
expect(() => { assertEntriesLoaded(ctxWith([
|
||||
{ fiber: {}, options: { name: 'ok' } },
|
||||
{ options: { name: 'broken-a' } },
|
||||
{ options: { name: 'broken-b' } },
|
||||
]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesActivated', () => {
|
||||
interface FakeFiber {
|
||||
state: number
|
||||
inject: Record<string, unknown>
|
||||
ctx: { get(name: string): unknown }
|
||||
await(): Promise<unknown>
|
||||
interface FakeEntry {
|
||||
fiber?: {
|
||||
state: number
|
||||
inject: Record<string, unknown>
|
||||
ctx: { get(name: string): unknown }
|
||||
await(): Promise<unknown>
|
||||
}
|
||||
disabled?: boolean
|
||||
options: { id: string; name: string }
|
||||
}
|
||||
|
||||
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
|
||||
loader: { entries: () => entries },
|
||||
const ctxWith = (entries: FakeEntry[]): Context => ({
|
||||
loader: { entries: () => entries.values() },
|
||||
}) as unknown as Context
|
||||
|
||||
const fiber = (
|
||||
@@ -538,77 +534,163 @@ describe('assertEntriesActivated', () => {
|
||||
error?: unknown,
|
||||
inject: Record<string, unknown> = {},
|
||||
services: string[] = [],
|
||||
): FakeFiber => ({
|
||||
): NonNullable<FakeEntry['fiber']> => ({
|
||||
state,
|
||||
inject,
|
||||
ctx: { get: name => services.includes(name) ? {} : undefined },
|
||||
await: error === undefined ? async () => undefined : async () => { throw error },
|
||||
})
|
||||
|
||||
it('passes active entries and ignores disabled entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const active = fiber(2)
|
||||
active.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
it('ignores active, disabled, and absent required entries', async () => {
|
||||
const warn = vi.fn()
|
||||
await expect(auditStartupEntries(ctxWith([]), NAME, warn)).resolves.toBeUndefined()
|
||||
for (const disabled of [false, true]) {
|
||||
await expect(auditStartupEntries(ctxWith(requiredIds.map(id => ({
|
||||
fiber: disabled ? fiber(3, new Error('disabled failure')) : fiber(2),
|
||||
disabled,
|
||||
options: { id, name: './required.mjs' },
|
||||
}))), NAME, warn)).resolves.toBeUndefined()
|
||||
}
|
||||
const disabled = fiber(3, new Error('disabled failure'))
|
||||
disabled.await = async () => {
|
||||
awaitCalls++
|
||||
throw new Error('disabled failure')
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: active, options: { name: 'active' } },
|
||||
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
|
||||
]), NAME)).resolves.toBeUndefined()
|
||||
expect(awaitCalls).toBe(0)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
|
||||
const original = new Error('actual plugin failure')
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
|
||||
it('warns once for optional import, apply, and dependency failures', async () => {
|
||||
const warn = vi.fn()
|
||||
const original = new Error('todo apply failure')
|
||||
await auditStartupEntries(ctxWith([
|
||||
{ options: { id: 'missing-tool', name: './missing.mjs' } },
|
||||
{ fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
|
||||
{
|
||||
fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']),
|
||||
options: { id: 'waiting-tool', name: './waiting.mjs' },
|
||||
},
|
||||
]), NAME, warn)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith([
|
||||
`${NAME}: warning: 3 entries did not activate`,
|
||||
'missing-tool (./missing.mjs): failed to import',
|
||||
`tool-todo (@deepseek-ai/dsh-tool-todo): ${original.stack!}`,
|
||||
'waiting-tool (./waiting.mjs): pending (waiting for service: missing)',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('formats stackless and non-Error activation failures', async () => {
|
||||
it.each([
|
||||
{ id: 'tool-todo', required: false },
|
||||
{ id: 'webserver', required: true },
|
||||
])('reports a throwing disabled expression on $id (required: $required)', async ({ id, required }) => {
|
||||
const error = new Error('disabled evaluation failed')
|
||||
const warn = vi.fn()
|
||||
const result = auditStartupEntries(ctxWith([{
|
||||
options: { id, name: './plugin.mjs' },
|
||||
get disabled(): boolean { throw error },
|
||||
}]), NAME, warn)
|
||||
const detail = `${id} (./plugin.mjs): disabled expression failed: ${error.stack!}`
|
||||
if (required) {
|
||||
await expect(result).rejects.toThrow(`required startup failure: 1 entry did not activate\n${detail}`)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} else {
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(`${NAME}: warning: 1 entry did not activate\n${detail}\n`)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves nested activation causes and aggregate member failures', async () => {
|
||||
const warn = vi.fn()
|
||||
const original = new Error('tool discovery failed')
|
||||
const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', {
|
||||
cause: new Error('server rejected discovery'),
|
||||
})
|
||||
const wrapper = new Error('plugin activation failed', { cause: aggregate })
|
||||
await auditStartupEntries(ctxWith([
|
||||
{ fiber: fiber(3, wrapper), options: { id: 'wrapped-plugin', name: './wrapped.mjs' } },
|
||||
]), NAME, warn)
|
||||
expect(warn).toHaveBeenCalledWith([
|
||||
`${NAME}: warning: 1 entry did not activate`,
|
||||
`wrapped-plugin (./wrapped.mjs): ${wrapper.stack!}`,
|
||||
aggregate.stack!,
|
||||
(aggregate.cause as Error).stack!,
|
||||
original.stack!,
|
||||
'transport closed',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('describes nested, stackless, non-error, pending, and unexpected failures', async () => {
|
||||
const warn = vi.fn()
|
||||
const circular = new Error('circular failure')
|
||||
;(circular as { cause?: unknown }).cause = circular
|
||||
const stackless = new Error('stackless failure')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
|
||||
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
|
||||
const deepestWithStack = new Error('deep failure with stack')
|
||||
const deepestWithoutStack = new Error('deep failure without stack')
|
||||
delete (deepestWithoutStack as { stack?: string }).stack
|
||||
const wrappedStack = new Error('wrapped stack', { cause: deepestWithStack })
|
||||
const wrappedStackless = new Error('wrapped stackless', { cause: deepestWithoutStack })
|
||||
const wrappedValue = new Error('wrapped value', { cause: 'plain cause' })
|
||||
|
||||
await auditStartupEntries(ctxWith([
|
||||
{ fiber: fiber(3, circular), options: { id: 'circular', name: './circular.mjs' } },
|
||||
{ fiber: fiber(3, stackless), options: { id: 'stackless', name: './stackless.mjs' } },
|
||||
{
|
||||
fiber: fiber(3, wrappedStack),
|
||||
options: { id: 'deep-stack', name: './deep-stack.mjs' },
|
||||
},
|
||||
{
|
||||
fiber: fiber(3, wrappedStackless),
|
||||
options: { id: 'deep-stackless', name: './deep-stackless.mjs' },
|
||||
},
|
||||
{
|
||||
fiber: fiber(3, wrappedValue),
|
||||
options: { id: 'plain-cause', name: './plain-cause.mjs' },
|
||||
},
|
||||
{ fiber: fiber(3, 42), options: { id: 'number-error', name: './number-error.mjs' } },
|
||||
{
|
||||
fiber: fiber(0, undefined, { first: {}, second: {} }),
|
||||
options: { id: 'multiple-dependencies', name: './multiple-dependencies.mjs' },
|
||||
},
|
||||
{
|
||||
fiber: fiber(0),
|
||||
options: { id: 'unknown-dependency', name: './unknown-dependency.mjs' },
|
||||
},
|
||||
{ fiber: fiber(1), options: { id: 'unexpected-state', name: './unexpected-state.mjs' } },
|
||||
]), NAME, warn)
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
const diagnostic = String(warn.mock.calls[0]![0])
|
||||
expect(diagnostic).toContain(`${NAME}: warning: 9 entries did not activate`)
|
||||
expect(diagnostic).toContain(`circular (./circular.mjs): ${circular.stack!}`)
|
||||
expect(diagnostic).toContain('stackless (./stackless.mjs): stackless failure')
|
||||
expect(diagnostic).toContain(`deep-stack (./deep-stack.mjs): ${wrappedStack.stack!}\n${deepestWithStack.stack!}`)
|
||||
expect(diagnostic).toContain(`deep-stackless (./deep-stackless.mjs): ${wrappedStackless.stack!}\ndeep failure without stack`)
|
||||
expect(diagnostic).toContain(`plain-cause (./plain-cause.mjs): ${wrappedValue.stack!}\nplain cause`)
|
||||
expect(diagnostic).toContain('number-error (./number-error.mjs): 42')
|
||||
expect(diagnostic).toContain('multiple-dependencies (./multiple-dependencies.mjs): pending (waiting for services: first, second)')
|
||||
expect(diagnostic).toContain('unknown-dependency (./unknown-dependency.mjs): pending (waiting for services: unknown)')
|
||||
expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1')
|
||||
})
|
||||
|
||||
it('reports unresolved services for pending entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const expected = [
|
||||
`${NAME}: 3 entries did not activate`,
|
||||
'waiting: pending (waiting for services: missingA, missingB)',
|
||||
'single-wait: pending (waiting for service: missing)',
|
||||
'unknown-wait: pending (waiting for services: unknown)',
|
||||
].join('\n')
|
||||
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
|
||||
const singleWait = fiber(0, undefined, { missing: {} })
|
||||
const unknownWait = fiber(0)
|
||||
for (const item of [waiting, singleWait, unknownWait]) {
|
||||
item.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: waiting, options: { name: 'waiting' } },
|
||||
{ fiber: singleWait, options: { name: 'single-wait' } },
|
||||
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
|
||||
]), NAME)).rejects.toThrow(expected)
|
||||
expect(awaitCalls).toBe(0)
|
||||
it.each(requiredIds)('rejects required %s failures after warning about optional failures', async (id) => {
|
||||
const warn = vi.fn()
|
||||
const requiredError = new Error('address already in use')
|
||||
const optionalError = new Error('todo unavailable')
|
||||
await expect(auditStartupEntries(ctxWith([
|
||||
{ fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } },
|
||||
{ fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } },
|
||||
]), NAME, warn)).rejects.toThrow([
|
||||
'required startup failure: 1 entry did not activate',
|
||||
`${id} (./required.mjs): ${requiredError.stack!}`,
|
||||
].join('\n'))
|
||||
expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`)
|
||||
})
|
||||
|
||||
it('retains the numeric diagnostic for a settled unexpected state', async () => {
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(4), options: { name: 'disposed' } },
|
||||
]), NAME)).rejects.toThrow('disposed: fiber state 4')
|
||||
it('rejects a required entry pending on an injected service', async () => {
|
||||
await expect(auditStartupEntries(ctxWith([{
|
||||
fiber: fiber(0, undefined, { headlessStartup: {} }),
|
||||
options: { id: 'headless-runner', name: '@deepseek-ai/dsh-headless' },
|
||||
}]), NAME, vi.fn())).rejects.toThrow(
|
||||
'headless-runner (@deepseek-ai/dsh-headless): pending (waiting for service: headlessStartup)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -729,22 +811,6 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('disposes partial host setup and labels non-Error preparation failures', async () => {
|
||||
const dir = tmp()
|
||||
const failure = 42
|
||||
let disposed = false
|
||||
const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
|
||||
ctx.effect(() => () => { disposed = true })
|
||||
throw failure
|
||||
})
|
||||
|
||||
await expect(task).rejects.toMatchObject({
|
||||
message: `${NAME}: host preparation failed: ${failure}`,
|
||||
cause: failure,
|
||||
})
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes dshHomePath to Loader config expressions', async () => {
|
||||
const dir = tmp()
|
||||
const dshHome = join(dir, 'home')
|
||||
@@ -802,48 +868,140 @@ describe('boot', () => {
|
||||
expect(ctx.get('loader')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
it('returns when disposal completes before root entry creation returns', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
|
||||
`${NAME}: plugin tree failed to load: failed to apply loader entry`,
|
||||
)
|
||||
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), [], (ctx) => {
|
||||
const create = ctx.loader.create.bind(ctx.loader)
|
||||
vi.spyOn(ctx.loader, 'create').mockImplementation(async (...args) => {
|
||||
const id = await create(...args)
|
||||
await ctx.fiber.dispose()
|
||||
return id
|
||||
})
|
||||
})
|
||||
expect(ctx.get('loader')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('labels a deferred config failure with its row and leaves the source file unchanged', async () => {
|
||||
it('keeps successful entries and warns about optional import, config, disabled, sync apply, async apply, and dependency failures', async () => {
|
||||
const dir = tmp()
|
||||
const configPath = join(dir, 'cordis.yml')
|
||||
const config = [
|
||||
'- id: good',
|
||||
' name: ./good.mjs',
|
||||
'- id: import-failure',
|
||||
' name: ./missing.mjs',
|
||||
'- id: invalid-config',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: !!js "JSON.parse(\'invalid\')"',
|
||||
'- id: disabled-failure',
|
||||
' name: ./noop.mjs',
|
||||
' disabled: !!js "JSON.parse(\'invalid\')"',
|
||||
'- id: sync-failure',
|
||||
' name: ./sync-failure.mjs',
|
||||
'- id: async-failure',
|
||||
' name: ./async-failure.mjs',
|
||||
'- id: waiting',
|
||||
' name: ./waiting.mjs',
|
||||
'',
|
||||
].join('\n')
|
||||
writeFileSync(join(dir, 'good.mjs'), 'export function apply(ctx) { ctx.provide("goodStarted", true) }\n')
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'sync-failure.mjs'), 'export function apply() { throw new Error("sync apply failure") }\n')
|
||||
writeFileSync(join(dir, 'async-failure.mjs'), 'export async function apply() { throw new Error("async apply failure") }\n')
|
||||
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
|
||||
writeFileSync(configPath, config)
|
||||
|
||||
await expect(boot(NAME, configPath)).rejects.toThrow(
|
||||
'failed to apply loader entry invalid-config (./noop.mjs)',
|
||||
)
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(config)
|
||||
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
let ctx: Context | undefined
|
||||
try {
|
||||
ctx = await boot(NAME, configPath)
|
||||
expect(ctx.get('goodStarted')).toBe(true)
|
||||
const entries = [...ctx.loader.entries()]
|
||||
expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)
|
||||
expect(entries.find(entry => entry.options.id === 'import-failure')?.fiber).toBeUndefined()
|
||||
expect(entries.find(entry => entry.options.id === 'disabled-failure')?.fiber).toBeUndefined()
|
||||
for (const id of ['invalid-config', 'sync-failure', 'async-failure']) {
|
||||
expect(entries.find(entry => entry.options.id === id)?.fiber?.state).toBe(3)
|
||||
}
|
||||
expect(entries.find(entry => entry.options.id === 'waiting')?.fiber?.state).toBe(0)
|
||||
const warning = write.mock.calls.map(call => String(call[0])).join('')
|
||||
expect(warning).toContain(`${NAME}: warning: 6 entries did not activate`)
|
||||
expect(warning).toContain('import-failure (./missing.mjs): failed to import')
|
||||
expect(warning).toContain('disabled-failure (./noop.mjs): disabled expression failed: SyntaxError')
|
||||
expect(warning).toContain('SyntaxError: Unexpected token')
|
||||
expect(warning).toContain('sync apply failure')
|
||||
expect(warning).toContain('async apply failure')
|
||||
expect(warning).toContain('waiting for service: neverProvided')
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(config)
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
await ctx?.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('appends the deepest cause with its original stack to the load failure', async () => {
|
||||
it.each([
|
||||
['missing', undefined, 'config file not found'],
|
||||
['malformed', 'invalid: [unclosed\n', 'unexpected end'],
|
||||
['non-array', 'entries: []\n', 'top-level array'],
|
||||
])('rejects a %s root configuration', async (_kind, content, message) => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'failing.mjs'), [
|
||||
'export function apply() {',
|
||||
" const failure = new Error('pinned activation failure')",
|
||||
" failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
|
||||
' throw failure',
|
||||
const configPath = join(dir, 'cordis.yml')
|
||||
if (content !== undefined) writeFileSync(configPath, content)
|
||||
let ctx: Context | undefined
|
||||
try {
|
||||
await expect(boot(NAME, configPath).then((value) => { ctx = value })).rejects.toThrow(message)
|
||||
} finally {
|
||||
await ctx?.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['import', undefined, '', 'failed to import'],
|
||||
['config schema', 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate() { return { issues: [{ message: "schema failure" }] } } } }\nexport function apply() {}\n', '', 'schema failure'],
|
||||
['config expression', 'export function apply() {}\n', ' config: { value: !!js "JSON.parse(\'invalid\')" }\n', 'SyntaxError'],
|
||||
['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'required startup failure: 1 entry did not activate\nwebserver (./required.mjs): disabled expression failed: SyntaxError'],
|
||||
['sync apply', 'export function apply() { throw new Error("sync failure") }\n', '', 'sync failure'],
|
||||
['async apply', 'export async function apply() { await Promise.resolve(); throw new Error("async failure") }\n', '', 'async failure'],
|
||||
['missing dependency', 'export const inject = ["missingRequiredService"]\nexport function apply() {}\n', '', 'missingRequiredService'],
|
||||
])('disposes startup after a required %s failure', async (_kind, source, config, message) => {
|
||||
const dir = tmp()
|
||||
if (source !== undefined) writeFileSync(join(dir, 'required.mjs'), source)
|
||||
writeFileSync(join(dir, 'cordis.yml'), `- id: webserver\n name: ./required.mjs\n${config}`)
|
||||
let disposed = false
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
|
||||
ctx.effect(() => () => { disposed = true })
|
||||
})).rejects.toThrow(message)
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('disposes successful entries and rejects when a required entry fails', async () => {
|
||||
const dir = tmp()
|
||||
let disposed = false
|
||||
writeFileSync(join(dir, 'good.mjs'), [
|
||||
'export function apply(ctx) {',
|
||||
' globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = false',
|
||||
' ctx.effect(() => () => { globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = true })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
|
||||
writeFileSync(join(dir, 'required-failure.mjs'), 'export function apply() { throw new Error("required apply failure") }\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: good',
|
||||
' name: ./good.mjs',
|
||||
'- id: webserver',
|
||||
' name: ./required-failure.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
|
||||
String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
|
||||
String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
|
||||
].join('')))
|
||||
'plugin tree failed to load: required startup failure: 1 entry did not activate',
|
||||
String.raw`webserver \(\.\/required-failure\.mjs\):`,
|
||||
'required apply failure',
|
||||
].join(String.raw`[\s\S]*`)))
|
||||
disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false
|
||||
delete (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the deepest cause message when its stack was erased', async () => {
|
||||
@@ -857,6 +1015,31 @@ describe('boot', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a non-Error rejection from host preparation', async () => {
|
||||
await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], () => {
|
||||
throw 'host refused'
|
||||
})).rejects.toThrow(`${NAME}: host preparation failed: host refused`)
|
||||
})
|
||||
|
||||
it.each([false, true])('rejects and disposes when an error cause is cyclic (indirect: %s)', async (indirect) => {
|
||||
const failure = new Error('cyclic setup failure')
|
||||
const next = indirect ? new Error('nested failure', { cause: failure }) : failure
|
||||
let reads = 0
|
||||
Object.defineProperty(failure, 'cause', {
|
||||
get() {
|
||||
// Bound a regressed synchronous traversal so it cannot hang the test worker.
|
||||
if (++reads > 10) throw new Error('cause traversal did not terminate')
|
||||
return next
|
||||
},
|
||||
})
|
||||
const dispose = vi.fn()
|
||||
await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], (ctx) => {
|
||||
ctx.effect(() => dispose)
|
||||
throw failure
|
||||
})).rejects.toThrow(`${NAME}: host preparation failed: cyclic setup failure`)
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('expands a stackless aggregate at the deepest activation cause', async () => {
|
||||
const dir = tmp()
|
||||
const aggregate = new AggregateError([
|
||||
@@ -879,15 +1062,6 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
|
||||
`${NAME}: 1 entry did not activate`,
|
||||
'./waiting.mjs: pending (waiting for service: neverProvided)',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('addHarnessSourceSection', () => {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Transactional config replacement through the booted Include and Loader tree.
|
||||
* HMR contains rejected refreshes; direct callers receive the error after the
|
||||
* previous generation has been retained or restored.
|
||||
*/
|
||||
/** File reload and overlay behavior through the booted Include tree. */
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
@@ -43,41 +39,40 @@ function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
function entryById(ctx: Context, id: string) {
|
||||
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
|
||||
if (!entry) throw new Error(`missing loader entry ${id}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
function plugin(name: string, body = ''): string {
|
||||
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
|
||||
}
|
||||
|
||||
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
|
||||
try {
|
||||
await task
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
|
||||
return
|
||||
}
|
||||
throw new Error(`expected loader update to fail during ${stage}`)
|
||||
}
|
||||
|
||||
describe('include refresh with an invalid file', () => {
|
||||
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
|
||||
it('writes and activates initial entries when an included file is missing', async () => {
|
||||
const { ctx, dir } = await bootTree([
|
||||
'- id: initialized',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./created.yml',
|
||||
' initial:',
|
||||
' - id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config: { value: initial }',
|
||||
'',
|
||||
].join('\n'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'initial' })
|
||||
expect(readFileSync(join(dir, 'created.yml'), 'utf8')).toContain('id: noop')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
||||
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
// An empty file parses to `undefined` without a YAML error; it must be
|
||||
// treated exactly like a parse failure, not crash the entry walk.
|
||||
writeFileSync(join(dir, 'cordis.yml'), '')
|
||||
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
||||
@@ -90,201 +85,6 @@ describe('include refresh with an invalid file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader entry replacement', () => {
|
||||
it('imports a changed name before replacing the running plugin', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'new.mjs': plugin('newPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
await entry.update({ name: './new.mjs' })
|
||||
expect(entry.options.name).toBe('./new.mjs')
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains the running plugin when the replacement cannot be imported', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous plugin after replacement application fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const previous = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === previous).toBe(false)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous config when an in-place restart fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not persist a failed direct fiber update', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
if (!fiber) throw new Error('target entry has no fiber')
|
||||
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader tree replacement', () => {
|
||||
it('rolls back earlier updates and additions when a later entry fails', async () => {
|
||||
const { ctx, dir, include } = await bootTree([
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: old',
|
||||
'',
|
||||
].join('\n'), {
|
||||
'configurable.mjs': plugin('configurablePlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: candidate',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'- id: bad',
|
||||
' name: ./bad.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: committed',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await include.refresh()
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
|
||||
expect(entryById(ctx, 'added').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
|
||||
// No manual builtin registration: `boot()` supplies `cordis:group` beside
|
||||
// `cordis:include`, which is what lets a composition give one `isolate`
|
||||
// realm to a provider and its consumers together.
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
|
||||
try {
|
||||
const config = (disabled: boolean) => [
|
||||
'- id: parent',
|
||||
' name: cordis:group',
|
||||
' group: true',
|
||||
` disabled: ${disabled}`,
|
||||
' config:',
|
||||
' - id: child',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(true))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeUndefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a programmatic entry move when its update fails', async () => {
|
||||
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
|
||||
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
|
||||
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
|
||||
const target = entryById(ctx, targetId)
|
||||
const source = target.parent
|
||||
const sourceIndex = source.data.indexOf(target.options)
|
||||
const destination = entryById(ctx, groupId).subgroup
|
||||
if (!destination) throw new Error('created loader group has no subgroup')
|
||||
|
||||
await expectUpdateFailure(
|
||||
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
|
||||
'apply',
|
||||
)
|
||||
|
||||
expect(target.parent).toBe(source)
|
||||
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
|
||||
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
|
||||
expect(destination.data).not.toContain(target.options)
|
||||
expect(target.options.config).toEqual({ fail: false })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include refresh with overlay patches', () => {
|
||||
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
||||
@@ -335,9 +135,9 @@ describe('include refresh with overlay patches', () => {
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
|
||||
// Omitting the patch list must remove the overlay rather than reuse the
|
||||
// Include's previous config through a default parameter.
|
||||
await entry.update({ config: { path: './base.yml' } })
|
||||
// Removing every patch must revert to the file's own values: patching
|
||||
// may not bake earlier patch results into the cached parse.
|
||||
await entry.update({ config: { path: './base.yml', patches: [] } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
||||
} finally {
|
||||
@@ -395,6 +195,71 @@ describe('include patches layered over one base', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('best-effort config failure recovery', () => {
|
||||
it.each([
|
||||
['import', undefined, undefined],
|
||||
['sync apply', 'export function apply(_ctx, config) { if (config.fail) throw new Error("reload sync failure") }\n', 3],
|
||||
['async apply', 'export async function apply(_ctx, config) { await Promise.resolve(); if (config.fail) throw new Error("reload async failure") }\n', 3],
|
||||
['dependency', 'export const inject = ["reloadMissing"]\nexport function apply() {}\n', 0],
|
||||
] as const)('keeps siblings after a required-id %s failure during HMR', async (_kind, source, state) => {
|
||||
const base = '- id: good\n name: ./noop.mjs\n'
|
||||
const { ctx, dir, include } = await bootTree(base, {
|
||||
...source === undefined ? {} : { 'failure.mjs': source },
|
||||
'provider.mjs': 'export function apply(ctx) { ctx.provide("reloadMissing", true) }\n',
|
||||
})
|
||||
try {
|
||||
const good = [...ctx.loader.entries()].find(entry => entry.options.id === 'good')!.fiber
|
||||
writeFileSync(join(dir, 'cordis.yml'), base + '- id: webserver\n name: ./failure.mjs\n config: { fail: true }\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
const failed = [...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')!
|
||||
expect(failed.fiber?.state).toBe(state)
|
||||
expect(good?.state).toBe(2)
|
||||
expect(ctx.fiber.state).toBe(2)
|
||||
|
||||
const recovery = `- id: webserver\n name: ./${source === undefined ? 'noop' : 'failure'}.mjs\n config: { fail: false }\n`
|
||||
const provider = state === 0 ? '- id: provider\n name: ./provider.mjs\n' : ''
|
||||
writeFileSync(join(dir, 'cordis.yml'), base + recovery + provider)
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect([...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')?.fiber?.state).toBe(2)
|
||||
expect([...ctx.loader.entries()].find(entry => entry.options.id === 'good')?.fiber).toBe(good)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the previous fiber config after schema rejection and retries a valid edit', async () => {
|
||||
const config = (value: number | string): string => `- id: webserver\n name: ./schema.mjs\n config: { value: ${JSON.stringify(value)} }\n`
|
||||
const { ctx, dir, include } = await bootTree(config(1), {
|
||||
'schema.mjs': [
|
||||
'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate(config) {',
|
||||
' return typeof config.value === "number" ? { value: config } : { issues: [{ message: "expected number" }] }',
|
||||
'} } }',
|
||||
'export function apply(ctx, config) { ctx.provide("validatedValue", config.value) }',
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
try {
|
||||
writeFileSync(join(dir, 'cordis.yml'), config('invalid'))
|
||||
await include.refresh()
|
||||
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(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(ctx.get('validatedValue')).toBe(2)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('shipped builtins', () => {
|
||||
it('lets a booted composition share one isolate realm across a group of rows', async () => {
|
||||
// The reason `boot()` registers `cordis:group`: a composition — notably an
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** Every per-test tree root, removed once the booted watcher has been disposed. */
|
||||
const hmrRoots: string[] = []
|
||||
|
||||
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, {
|
||||
root,
|
||||
ignored: [],
|
||||
debounce: 0,
|
||||
...usePolling === undefined ? {} : { usePolling },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('HMR exact config paths', () => {
|
||||
afterEach(() => {
|
||||
for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
const aliasFilename = join(alias, 'module.ts')
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
writeFileSync(aliasFilename, 'export const generation = 0\n')
|
||||
// This acceptance owns alias-to-cache identity. Other cases below exercise
|
||||
// native events; polling keeps Windows fs.watch queue pressure out of it.
|
||||
const ctx = await bootHmr(alias, ['.'], true)
|
||||
const filename = join(await realpath(target), 'module.ts')
|
||||
const expected = pathToFileURL(filename).href
|
||||
const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
|
||||
const observed: string[] = []
|
||||
ctx.on('hmr/change', (url) => { observed.push(url) })
|
||||
try {
|
||||
const deadline = Date.now() + 20_000
|
||||
for (let generation = 1; !observed.includes(expected); generation += 1) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
|
||||
}
|
||||
// The watch base, not the writer spelling, is the alias under test.
|
||||
// Grow the file on every write: polling must not depend on timestamp
|
||||
// precision when several generations land inside one filesystem tick.
|
||||
writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
|
||||
// Leave Chokidar's atomic-write window idle so one coalesced change can publish.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
expect(cacheHas).toHaveBeenCalledWith(expected)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
unlinkSync(alias)
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('collapses filesystem aliases before registering an exact watch', async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const ctx = await bootHmr(alias)
|
||||
try {
|
||||
await ctx.hmr.registerConfig('plugins.yml', () => {})
|
||||
await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {}))
|
||||
.rejects.toThrow('config path already registered')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
unlinkSync(alias)
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
try {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
observed.push('missing')
|
||||
}
|
||||
})
|
||||
|
||||
writeFileSync(filename, 'one', { flag: 'wx' })
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
|
||||
writeFileSync(filename, 'two')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
|
||||
unlinkSync(filename)
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(root)
|
||||
const dir = join(root, 'later')
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(root)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
})
|
||||
mkdirSync(dir)
|
||||
writeFileSync(filename, 'created')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
writeFileSync(filename, 'one')
|
||||
const ctx = await bootHmr(dir)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const observed: string[] = []
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
try {
|
||||
const dispose = await ctx.hmr.registerConfig(filename, async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
if (observed.length === 1) {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
active -= 1
|
||||
})
|
||||
await started.promise
|
||||
writeFileSync(filename, 'two')
|
||||
// Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
|
||||
// that window so this edit is queued before registration disposal.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(maxActive).toBe(1)
|
||||
expect(observed).toEqual(['one', 'two'])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
|
||||
let failureCount = 0
|
||||
try {
|
||||
ctx.on('hmr/config-update-failed', () => {
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failureCount += 1
|
||||
failure.resolve({ filename: failedFilename, error })
|
||||
})
|
||||
await ctx.hmr.registerConfig(filename, () => { throw 42 })
|
||||
writeFileSync(filename, 'invalid')
|
||||
|
||||
const observed = await failure.promise
|
||||
expect(observed.filename).toBe(filename)
|
||||
expect(observed.error).toBeInstanceOf(Error)
|
||||
expect(observed.error.message).toBe('42')
|
||||
|
||||
// Let Chokidar's atomic-write window close before requiring a distinct
|
||||
// second notification from the same path.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
writeFileSync(filename, 'invalid again')
|
||||
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
|
||||
* (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
|
||||
* a real Loader tree, kept live through transactional HMR.
|
||||
* a real Loader tree with live file watching.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
@@ -178,15 +178,15 @@ describe('loadOptionalPatches', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function writeTree(dir: string): string {
|
||||
function writeTree(dir: string, id = 'noop', asyncApply = false): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
'export function apply(_ctx, config = {}) {',
|
||||
`export ${asyncApply ? 'async ' : ''}function apply(_ctx, config = {}) {`,
|
||||
' if (config.fail) throw new Error("candidate config failed")',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), `- id: ${id}\n name: ./noop.mjs\n config:\n value: base\n`)
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
|
||||
@@ -270,7 +270,9 @@ describe('Loader config interpolation', () => {
|
||||
await provider?.update({ disabled: true })
|
||||
await provider?.update({ config: { fail: true } })
|
||||
await provider?.update({ disabled: false })
|
||||
await expect(ctx.loader.await()).rejects.toThrow('rejected provider')
|
||||
await ctx.loader.await()
|
||||
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
|
||||
await expect(reader?.fiber?.await()).rejects.toThrow('rejected provider')
|
||||
expect(ctx.get('readerResult')).toBeUndefined()
|
||||
|
||||
await provider?.update({ disabled: true })
|
||||
@@ -342,7 +344,8 @@ describe('Loader entry disabled interpolation', () => {
|
||||
const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
|
||||
await entry?.update({ disabled: disabledTrue })
|
||||
expect(entry?.disabled).toBe(true)
|
||||
expect(entry?.fiber).toBeUndefined()
|
||||
await ctx.loader.await()
|
||||
expect(entry?.fiber?.uid).toBeNull()
|
||||
await entry?.update({ disabled: disabledFalse })
|
||||
expect(entry?.disabled).toBe(false)
|
||||
expect(entry?.fiber).toBeDefined()
|
||||
@@ -403,17 +406,21 @@ describe('boot with user patches', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
|
||||
it.each([
|
||||
{ id: 'noop', asyncApply: false },
|
||||
{ id: 'webserver', asyncApply: false },
|
||||
{ id: 'webserver', asyncApply: true },
|
||||
])('keeps HMR best effort and recovers ($id, async apply: $asyncApply)', { timeout: 20_000 }, async ({ id, asyncApply }) => {
|
||||
const dir = tmp()
|
||||
const userDir = tmp()
|
||||
const filename = join(userDir, PROFILE_PATCH_FILENAME)
|
||||
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
|
||||
const ctx = await boot(NAME, writeTree(dir), basePatches)
|
||||
const basePatches = [{ id, config: { value: 'generated' } }]
|
||||
const ctx = await boot(NAME, writeTree(dir, id, asyncApply), basePatches)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
// Native notifications belong to hmr-config.spec.ts; this case owns the
|
||||
// real HMR/Include transaction after each delivered filesystem event.
|
||||
// Native notifications belong to watch-config.spec.ts; this case owns
|
||||
// Include recomposition after each explicitly delivered filesystem event.
|
||||
const watchers: FSWatcher[] = []
|
||||
const previousFactory = configWatch.create
|
||||
onTestFinished(() => { configWatch.create = previousFactory })
|
||||
@@ -423,10 +430,11 @@ describe('boot with user patches', () => {
|
||||
queueMicrotask(() => { watcher.emit('ready') })
|
||||
return watcher
|
||||
}
|
||||
const failures: Array<{ filename: string; error: Error }> = []
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failures.push({ filename: failedFilename, error })
|
||||
const failures: Error[] = []
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation((value: unknown) => {
|
||||
if (value instanceof Error) failures.push(value)
|
||||
})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
const dispose = await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename,
|
||||
@@ -435,31 +443,40 @@ describe('boot with user patches', () => {
|
||||
expect(watchers).toHaveLength(1)
|
||||
const watcher = watchers[0]!
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
|
||||
writeFileSync(filename, `- id: ${id}\n config:\n value: live\n`)
|
||||
watcher.emit('add', filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')
|
||||
await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'live', 'user patch addition was not applied')
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
|
||||
writeFileSync(filename, `- id: ${id}\n config:\n fail: true\n`)
|
||||
watcher.emit('change', filename)
|
||||
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
|
||||
expect(failures[0]).toMatchObject({ filename })
|
||||
expect(failures[0]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await eventually(() => failures.length === 1, 'failed candidate was not reported')
|
||||
expect(failures[0]).toBeInstanceOf(Error)
|
||||
expect(entryConfig(ctx, id)).toMatchObject({ fail: true })
|
||||
|
||||
writeFileSync(filename, `- id: ${id}\n disabled: !!js "JSON.parse('invalid')"\n`)
|
||||
watcher.emit('change', filename)
|
||||
await eventually(() => failures.length === 2, 'disabled expression failure was not reported')
|
||||
expect(failures[1]?.message).toContain(`${id} (./noop.mjs): disabled expression failed: SyntaxError`)
|
||||
expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
|
||||
.toEqual({ __jsExpr: "JSON.parse('invalid')" })
|
||||
|
||||
writeFileSync(filename, 'invalid: [unclosed\n')
|
||||
watcher.emit('change', filename)
|
||||
await eventually(() => failures.length === 2, 'parse failure was not broadcast')
|
||||
expect(failures[1]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await eventually(() => failures.length === 3, 'parse failure was not reported')
|
||||
expect(failures[2]).toBeInstanceOf(Error)
|
||||
expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
|
||||
.toEqual({ __jsExpr: "JSON.parse('invalid')" })
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: recovered\n')
|
||||
writeFileSync(filename, `- id: ${id}\n config:\n value: recovered\n`)
|
||||
watcher.emit('change', filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
|
||||
await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'recovered', 'valid recovery was not applied')
|
||||
await ctx.loader.await()
|
||||
expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.fiber?.state).toBe(2)
|
||||
|
||||
unlinkSync(filename)
|
||||
watcher.emit('unlink', filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(2)
|
||||
await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(3)
|
||||
|
||||
// Default compose: the user layer IS the whole patch list, so a
|
||||
// fresh generation replaces the app-owned layer instead of stacking on it.
|
||||
@@ -467,9 +484,9 @@ describe('boot with user patches', () => {
|
||||
const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
expect(watchers).toHaveLength(2)
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
|
||||
writeFileSync(filename, `- id: ${id}\n config:\n value: identity\n`)
|
||||
watchers[1]!.emit('add', filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
|
||||
await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
|
||||
} finally {
|
||||
await disposeDefault()
|
||||
}
|
||||
@@ -494,21 +511,24 @@ describe('boot with user patches', () => {
|
||||
})
|
||||
|
||||
it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
|
||||
// A surface can dispose the whole tree while registerConfig's effect
|
||||
// registration is still in flight (the HMR effect then fails with
|
||||
// INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher
|
||||
// must not crash the process. The stub makes the race deterministic — the
|
||||
// live-teardown ordering itself is not stageable.
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
|
||||
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const previousFactory = configWatch.create
|
||||
onTestFinished(() => { configWatch.create = previousFactory })
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.effect(() => () => release.promise)
|
||||
let disposal: Promise<void> | undefined
|
||||
onTestFinished(async () => { release.resolve(undefined); await disposal })
|
||||
configWatch.create = (options) => {
|
||||
const watcher = new FSWatcher(options)
|
||||
disposal = ctx.fiber.dispose()
|
||||
queueMicrotask(() => { watcher.emit('ready') })
|
||||
return watcher
|
||||
}
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates registration failures other than mid-teardown', async () => {
|
||||
@@ -519,7 +539,7 @@ describe('boot with user patches', () => {
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
// Same user-layer path registered twice: HMR refuses; not a teardown race.
|
||||
// Same user-layer path registered twice: the watcher refuses; not a teardown race.
|
||||
await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
|
||||
await dispose()
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, parse } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { watchConfig } from '../src/watch-config.ts'
|
||||
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { FSWatcher, type ChokidarOptions } from 'chokidar'
|
||||
|
||||
const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined }))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const native = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...native, stat: vi.fn(native.stat) }
|
||||
})
|
||||
vi.mock('chokidar', async (importOriginal) => {
|
||||
const native = await importOriginal<typeof import('chokidar')>()
|
||||
return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) =>
|
||||
configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) }
|
||||
})
|
||||
|
||||
/** Every per-test tree root, removed once the booted watcher has been disposed. */
|
||||
const hmrRoots: string[] = []
|
||||
|
||||
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, {
|
||||
root,
|
||||
ignored: [],
|
||||
debounce: 0,
|
||||
...usePolling === undefined ? {} : { usePolling },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('HMR exact config paths', () => {
|
||||
afterEach(() => {
|
||||
for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
const aliasFilename = join(alias, 'module.ts')
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
writeFileSync(aliasFilename, 'export const generation = 0\n')
|
||||
// This acceptance owns alias-to-cache identity. Other cases below exercise
|
||||
// native events; polling keeps Windows fs.watch queue pressure out of it.
|
||||
const ctx = await bootHmr(alias, ['.'], true)
|
||||
const filename = join(await realpath(target), 'module.ts')
|
||||
const expected = pathToFileURL(filename).href
|
||||
const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
|
||||
const observed: string[] = []
|
||||
ctx.on('hmr/change', (url) => { observed.push(url) })
|
||||
try {
|
||||
const deadline = Date.now() + 20_000
|
||||
for (let generation = 1; !observed.includes(expected); generation += 1) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
|
||||
}
|
||||
// The watch base, not the writer spelling, is the alias under test.
|
||||
// Grow the file on every write: polling must not depend on timestamp
|
||||
// precision when several generations land inside one filesystem tick.
|
||||
writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
|
||||
// Leave Chokidar's atomic-write window idle so one coalesced change can publish.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
expect(cacheHas).toHaveBeenCalledWith(expected)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
unlinkSync(alias)
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('collapses filesystem aliases before registering an exact watch', async () => {
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
|
||||
const alias = `${target}-alias`
|
||||
symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const ctx = await bootHmr(alias)
|
||||
try {
|
||||
await watchConfig(ctx, join(alias, 'plugins.yml'), {}, () => {})
|
||||
await expect(watchConfig(ctx, join(await realpath(target), 'plugins.yml'), {}, () => {}))
|
||||
.rejects.toThrow('config path already registered')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
unlinkSync(alias)
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await watchConfig(ctx, filename, {}, () => {
|
||||
try {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
observed.push('missing')
|
||||
}
|
||||
})
|
||||
|
||||
writeFileSync(filename, 'one', { flag: 'wx' })
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
|
||||
writeFileSync(filename, 'two')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
|
||||
unlinkSync(filename)
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(root)
|
||||
const dir = join(root, 'later')
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(root)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await watchConfig(ctx, filename, {}, () => {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
})
|
||||
mkdirSync(dir)
|
||||
writeFileSync(filename, 'created')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes refreshes and waits for them during disposal', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const watcher = new FSWatcher()
|
||||
const previousFactory = configWatch.create
|
||||
onTestFinished(() => { configWatch.create = previousFactory })
|
||||
configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => { release.resolve(undefined) })
|
||||
let calls = 0
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const dispose = await watchConfig(ctx, filename, {}, async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
if (++calls === 1) {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
active -= 1
|
||||
})
|
||||
watcher.emit('change', join(dir, 'unrelated.yml'))
|
||||
expect(calls).toBe(0)
|
||||
watcher.emit('add', filename)
|
||||
await started.promise
|
||||
watcher.emit('change', filename)
|
||||
watcher.emit('unlink', filename)
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(maxActive).toBe(1)
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects a patch path whose parent is a regular file', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-'))
|
||||
hmrRoots.push(dir)
|
||||
const parent = join(dir, 'file')
|
||||
writeFileSync(parent, '')
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await expect(watchConfig(ctx, join(parent, 'plugins.yml'), {}, () => {}))
|
||||
.rejects.toThrow('config watch parent is not a directory')
|
||||
})
|
||||
|
||||
it('stops searching when the filesystem root cannot be read', async () => {
|
||||
const failure = Object.assign(new Error('filesystem root unavailable'), { code: 'ENOENT' })
|
||||
const read = vi.mocked(fsPromises.stat).mockClear().mockRejectedValueOnce(failure)
|
||||
onTestFinished(() => { read.mockRestore() })
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await expect(watchConfig(ctx, join(parse(tmpdir()).root, 'plugins.yml'), {}, () => {})).rejects.toBe(failure)
|
||||
expect(read).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['creation', 'ready'] as const)('releases registration after watcher %s fails', async (phase) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-watch-failure-'))
|
||||
hmrRoots.push(dir)
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const previousFactory = configWatch.create
|
||||
onTestFinished(() => { configWatch.create = previousFactory })
|
||||
const failure = new Error('watcher unavailable')
|
||||
const failed = new FSWatcher()
|
||||
const closed = vi.spyOn(failed, 'close')
|
||||
configWatch.create = () => {
|
||||
if (phase === 'creation') throw failure
|
||||
queueMicrotask(() => { failed.emit('error', failure) })
|
||||
return failed
|
||||
}
|
||||
await expect(watchConfig(ctx, filename, {}, () => {})).rejects.toBe(failure)
|
||||
if (phase === 'ready') expect(closed).toHaveBeenCalledOnce()
|
||||
const watcher = new FSWatcher()
|
||||
configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
|
||||
await watchConfig(ctx, filename, {}, () => {})
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
watcher.emit('error', failure)
|
||||
expect(warn).toHaveBeenCalledWith(failure)
|
||||
})
|
||||
|
||||
it('closes a ready watcher when its context has already been disposed', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-disposed-'))
|
||||
hmrRoots.push(dir)
|
||||
const root = new Context()
|
||||
const fiber = root.plugin(() => {})
|
||||
await fiber
|
||||
const ctx = fiber.ctx
|
||||
await fiber.dispose()
|
||||
const watcher = new FSWatcher()
|
||||
const close = vi.spyOn(watcher, 'close')
|
||||
const previousFactory = configWatch.create
|
||||
onTestFinished(() => { configWatch.create = previousFactory })
|
||||
configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
|
||||
await expect(watchConfig(ctx, join(dir, 'plugins.yml'), {}, () => {}))
|
||||
.rejects.toThrow('cannot create effect on inactive context')
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('logs a normalized refresh failure and continues processing later events', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-failure-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const watcher = new FSWatcher()
|
||||
const previousFactory = configWatch.create
|
||||
onTestFinished(() => { configWatch.create = previousFactory })
|
||||
configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
let calls = 0
|
||||
const recovered = Promise.withResolvers<undefined>()
|
||||
await watchConfig(ctx, filename, {}, () => {
|
||||
if (++calls === 1) throw 42
|
||||
recovered.resolve(undefined)
|
||||
})
|
||||
watcher.emit('change', filename)
|
||||
await expect.poll(() => warn.mock.calls.length).toBe(2)
|
||||
expect(warn.mock.calls[0]).toEqual(['config reload at %C failed', filename])
|
||||
expect(warn.mock.calls[1]?.[0]).toMatchObject({ message: '42' })
|
||||
watcher.emit('change', filename)
|
||||
await recovered.promise
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
|
||||
README.md: aea694942173a856861d00a69f15b451cc930976
|
||||
README.zh.md: 4dab93ff03071bff6783ae89b0397216b86a8c05
|
||||
README.md: 84630f1d9142b1e2300baa0cde0513acdb602da9
|
||||
README.zh.md: 0488c74f433b0ef1d7e6b1677b7f50fd21a198a2
|
||||
@@ -36,6 +36,10 @@ dsh --profile web --no-open --port 8080
|
||||
|
||||
After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the clean root page. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
|
||||
|
||||
**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Chat Completions; set `protocol: messages` in Cordis YAML to switch. Web has no protocol selector.
|
||||
|
||||
Saved model selections override the composition default. Both protocols share `deepseek-official` and `llm-deepseek` settings, so switching preserves model selections and credential references. Endpoint overrides retain their values; the settings card lets users supply a compatible API address.
|
||||
|
||||
### Configuration
|
||||
|
||||
Most users never set these; the command-line flags feed the four settings below — `--host`, `--port`, and `--trusted-host` come from the invocation, and `--no-open` turns the browser handoff off for that invocation:
|
||||
@@ -77,7 +81,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve
|
||||
|
||||
### Readiness
|
||||
|
||||
The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles and Connection authentication is available — or immediately in a hand-built tree without a Loader. A tree disposed mid-boot announces nothing.
|
||||
The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
|
||||
|
||||
### LAN trust sampling
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ dsh --profile web --no-open --port 8080
|
||||
|
||||
启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的根页面。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
|
||||
|
||||
**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Chat Completions;在 Cordis YAML 中设置 `protocol: messages` 可切换协议。Web 不提供协议选择器。
|
||||
|
||||
已保存的模型选择优先于组合默认值。两种协议共用 `deepseek-official` 与 `llm-deepseek` 设置,因此切换协议不改变模型选择或复制凭据。端点覆盖保持原值;设置卡片允许用户填写兼容的 API 地址。
|
||||
|
||||
### 配置
|
||||
|
||||
大多数用户不需要设置这些;命令行 flag 会提供给下面四个设置——`--host`、`--port` 与 `--trusted-host` 来自本次调用,`--no-open` 仅对本次调用关闭浏览器交接:
|
||||
@@ -77,7 +81,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己
|
||||
|
||||
### 就绪宣告
|
||||
|
||||
URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。启动中途被释放的树不会宣告任何内容。
|
||||
URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
|
||||
|
||||
### LAN 信任采样
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection'
|
||||
import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
|
||||
import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
||||
@@ -284,15 +284,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const settled = connectionCtx.get('loader')?.await()
|
||||
if (settled === undefined) announceReady()
|
||||
else {
|
||||
void settled.then(() => {
|
||||
void settled.then(async () => {
|
||||
await auditStartupEntries(connectionCtx.root, 'dsh web', () => {})
|
||||
// The tree can be disposed while the boot was in flight (early
|
||||
// SIGTERM); a URL line or browser tab for a dead server would only
|
||||
// mislead, and reading torn-down services would turn a clean shutdown
|
||||
// into a crash.
|
||||
if (connectionCtx.get('webServer') !== undefined
|
||||
&& connectionCtx.get('connection') !== undefined) announceReady()
|
||||
// Loader reports a failed boot; this row only stays quiet.
|
||||
}, () => {})
|
||||
}).catch(() => {
|
||||
// Boot owns the failure diagnostic; readiness remains unpublished.
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import * as AppBoot from '@deepseek-ai/dsh-app-boot'
|
||||
import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
@@ -100,7 +102,7 @@ function provideConnection(ctx: Context): void {
|
||||
|
||||
/** A fake Loader whose settlement the test controls (the URL line waits on it). */
|
||||
function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
|
||||
ctx.provide('loader', { await: settle } as never)
|
||||
ctx.provide('loader', { await: settle, entries: () => [] } as never)
|
||||
}
|
||||
|
||||
interface BashContribution {
|
||||
@@ -313,6 +315,39 @@ describe('web-app runtime glue', () => {
|
||||
await torn.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ id: 'webserver', announces: false },
|
||||
{ id: 'modules', announces: false },
|
||||
{ id: 'connection', announces: false },
|
||||
{ id: 'optional-tool', announces: true },
|
||||
])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
ctx.baseUrl = 'file:///'
|
||||
ctx.provide('webServer', fakeHttpServer().server)
|
||||
provideConnection(ctx)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.failure = () => { throw new Error('sibling rejected') }
|
||||
await ctx.loader.root.update([{ id, name: 'cordis:failure' }])
|
||||
await ctx.loader.await()
|
||||
await expect(ctx.loader.resolve(id).fiber?.await()).rejects.toThrow('sibling rejected')
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const openBrowser = vi.fn(async () => {})
|
||||
internals.openBrowser = openBrowser
|
||||
const audit = vi.spyOn(AppBoot, 'auditStartupEntries')
|
||||
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
|
||||
await vi.waitFor(() => { expect(audit).toHaveBeenCalledOnce() })
|
||||
await Promise.allSettled(audit.mock.results.map(result => result.value as Promise<void>))
|
||||
if (announces) {
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
|
||||
expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
|
||||
} else {
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
expect(openBrowser).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when the prompt section resolves against a portless webserver', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: 83ba5357cf84a12105518ed057fd120584b636cb
|
||||
README.zh.md: 9e5c75838e52fda7d74b074b9c1a9dacef7932a8
|
||||
README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f
|
||||
README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c
|
||||
@@ -71,6 +71,8 @@ The Node half snapshots each client bundle and available source map before publi
|
||||
|
||||
### Boot manifest injection
|
||||
|
||||
The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server.
|
||||
|
||||
The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -71,6 +71,8 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map
|
||||
|
||||
### 启动 manifest 注入
|
||||
|
||||
bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。
|
||||
|
||||
宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。
|
||||
|
||||
### 源码索引
|
||||
|
||||
@@ -504,6 +504,7 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Build the service: subscribe, seed, and run the activation flush.
|
||||
* Bundle routes follow the optional Web carrier's injected lifecycle.
|
||||
* @param ctx - plugin context carrying Loader and an optional Web carrier.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
@@ -540,8 +541,7 @@ export class ClientModuleRegistry extends Service {
|
||||
'client-modules: bundle route',
|
||||
)
|
||||
}
|
||||
if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier)
|
||||
else registerWebCarrier(ctx)
|
||||
ctx.inject(['webServer'], registerWebCarrier)
|
||||
ctx.on('webserver/index-inject', (table) => {
|
||||
table.push(...bootInjections(this.composed))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as modulesClient from '../src/client/index.ts'
|
||||
@@ -24,12 +24,50 @@ const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
|
||||
const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
|
||||
await ready
|
||||
await ctx.fiber.dispose()
|
||||
}))
|
||||
if (root !== undefined) rmSync(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
|
||||
const ctx = new Context()
|
||||
contexts.push({ ctx })
|
||||
ctx.provide('loader', { entries: () => [] })
|
||||
const routes = new Set<WebRoute>()
|
||||
const mountServer = () => ctx.plugin((serverCtx) => {
|
||||
serverCtx.provide('webServer', {
|
||||
register: (route: WebRoute) => {
|
||||
routes.add(route)
|
||||
return () => { routes.delete(route) }
|
||||
},
|
||||
} as WebServer)
|
||||
})
|
||||
let server = serverFirst ? await mountServer() : undefined
|
||||
const modules = await ctx.plugin(ClientModuleRegistry)
|
||||
const service = ctx.get('clientModules')!
|
||||
expect(service.graph().entries).toEqual([])
|
||||
expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
|
||||
if (!serverFirst) {
|
||||
expect(routes.size).toBe(0)
|
||||
server = await mountServer()
|
||||
}
|
||||
await expect.poll(() => routes.size).toBe(1)
|
||||
await server!.dispose()
|
||||
await expect.poll(() => routes.size).toBe(0)
|
||||
expect(modules.state).toBe(FiberState.ACTIVE)
|
||||
expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404)
|
||||
await mountServer()
|
||||
await expect.poll(() => routes.size).toBe(1)
|
||||
await modules.dispose()
|
||||
expect(routes.size).toBe(0)
|
||||
})
|
||||
|
||||
/** Create a resolvable package whose client export points at the returned path. */
|
||||
function writePackage(
|
||||
packageName: string,
|
||||
@@ -65,8 +103,10 @@ function constructWithRoute(
|
||||
entryBaseUrl?: string
|
||||
internal?: NonNullable<Context['loader']['internal']>
|
||||
} = {},
|
||||
): { context: Context; service: ClientModuleRegistry; route: WebRoute } {
|
||||
): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
|
||||
const ctx = new Context()
|
||||
const owned: typeof contexts[number] = { ctx }
|
||||
contexts.push(owned)
|
||||
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
internal: options.internal,
|
||||
@@ -81,19 +121,19 @@ function constructWithRoute(
|
||||
}
|
||||
},
|
||||
})
|
||||
let route: WebRoute | undefined
|
||||
const route = Promise.withResolvers<WebRoute>()
|
||||
const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: (candidate) => {
|
||||
if (candidate.path === '/plugins') route = candidate
|
||||
if (candidate.path === '/plugins') route.resolve(candidate)
|
||||
return () => {}
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
}
|
||||
ctx.provide('webServer', webServer as WebServer)
|
||||
const service = new ClientModuleRegistry(ctx)
|
||||
if (route === undefined) throw new Error('client bundle route was not registered')
|
||||
return { context: ctx, service, route }
|
||||
owned.ready = route.promise
|
||||
return { context: ctx, service, route: route.promise }
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
@@ -102,7 +142,7 @@ function construct(packageNames: string[]): ClientModuleRegistry {
|
||||
}
|
||||
|
||||
/** Invoke the registered plugin route and capture status, headers, and bytes. */
|
||||
async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
|
||||
async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
|
||||
status: number
|
||||
headers: Record<string, string> | undefined
|
||||
body: Buffer
|
||||
@@ -121,7 +161,7 @@ async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promi
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
await route.handler({ method, url } as IncomingMessage, response)
|
||||
await (await route).handler({ method, url } as IncomingMessage, response)
|
||||
return { status, headers, body }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 1d3e6c6d0774ed78bb0a1952ba57e5f607f24b47
|
||||
README.zh.md: e7f0419939076b1276586cfba28129b7df1ba355
|
||||
README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e
|
||||
README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc
|
||||
@@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel
|
||||
|
||||
### Controls and icons
|
||||
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Office mappings include XLSM/Numbers as Excel, KEY as slides, and RTF/ODT/Pages as documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Office mappings include XLSM/Numbers as Excel, KEY as slides, and RTF/ODT/Pages as documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, a connecting spinner beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
@@ -98,8 +98,6 @@ The package enforces one separation: presentational React atoms with zero Cordis
|
||||
| [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards |
|
||||
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards |
|
||||
| [`src/icons/`](src/icons/) | `ic_ds_*` glyph components and brand marks |
|
||||
| [`src/code-file-icon-artwork.ts`](src/code-file-icon-artwork.ts) | Embedded inner SVG markup for the 48 detailed code-file categories |
|
||||
| [`src/code-file-icon-artwork.manifest.json`](src/code-file-icon-artwork.manifest.json) | Design-export digests, included categories, and intentionally excluded artwork |
|
||||
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks |
|
||||
|
||||
### Streaming markdown
|
||||
|
||||
@@ -67,7 +67,7 @@ kind: "package-library"
|
||||
|
||||
### 控件与图标
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。办公文件映射包含 XLSM/Numbers 的表格图标、KEY 的幻灯片图标,以及 RTF/ODT/Pages 的文档图标。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。办公文件映射包含 XLSM/Numbers 的表格图标、KEY 的幻灯片图标,以及 RTF/ODT/Pages 的文档图标。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、spinner 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
@@ -98,8 +98,6 @@ kind: "package-library"
|
||||
| [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 |
|
||||
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 |
|
||||
| [`src/icons/`](src/icons/) | `ic_ds_*` 字形组件与品牌标记 |
|
||||
| [`src/code-file-icon-artwork.ts`](src/code-file-icon-artwork.ts) | 48 个细分代码文件类别的内嵌内层 SVG markup |
|
||||
| [`src/code-file-icon-artwork.manifest.json`](src/code-file-icon-artwork.manifest.json) | 设计导出摘要、已纳入类别与有意排除的图稿 |
|
||||
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 |
|
||||
|
||||
### 流式 Markdown
|
||||
|
||||
@@ -4,25 +4,49 @@
|
||||
grid-template-columns: 14px max-content;
|
||||
align-items: center;
|
||||
column-gap: 4px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 13px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
transition: background-color 160ms ease-out, color 160ms ease-out;
|
||||
transition:
|
||||
background-color 160ms ease-out,
|
||||
color 160ms ease-out,
|
||||
border-color 160ms ease-out,
|
||||
opacity 150ms ease-out;
|
||||
animation: indicator-enter 150ms ease-out;
|
||||
}
|
||||
|
||||
.leaving {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes indicator-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 20%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warning:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-tertiary),
|
||||
var(--dsw-alias-state-warn-primary) 6%
|
||||
);
|
||||
}
|
||||
|
||||
.warning:active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
@@ -39,6 +63,7 @@
|
||||
.success {
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -49,35 +74,20 @@
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stateLabel,
|
||||
.hoverLabel,
|
||||
.sizeLabel {
|
||||
grid-area: 1 / 1;
|
||||
.spinner {
|
||||
animation: spinner-rotate 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.sizeLabel {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.warning:is(:hover, :focus-visible) .stateLabel {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.hoverLabel {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.warning:is(:hover, :focus-visible) .hoverLabel {
|
||||
visibility: visible;
|
||||
@keyframes spinner-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
width: 1em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -100,8 +110,14 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator,
|
||||
.secondDot,
|
||||
.thirdDot {
|
||||
.thirdDot,
|
||||
.spinner {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconCheckOutline16, IconWarningOutline16 } from './icons/index.tsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { IconCheckOutline16, IconLoadingOutline16, IconRefreshOutline14 } from './icons/index.tsx'
|
||||
import css from './ConnectionIndicator.module.css'
|
||||
|
||||
/** Visual state rendered by {@link ConnectionIndicator}. */
|
||||
@@ -7,11 +8,16 @@ export type ConnectionIndicatorState =
|
||||
| 'connecting'
|
||||
| 'recovered'
|
||||
|
||||
/** Exit-transition length; keep equal to the `.leaving` transition duration in the stylesheet. */
|
||||
const EXIT_MS = 150
|
||||
|
||||
/**
|
||||
* Render an inline connection-recovery control.
|
||||
* Render an inline connection-recovery control. The outage and retry-attempt
|
||||
* states are one button whose static label already names the retry action;
|
||||
* clicking it requests an immediate reconnect. The indicator animates in on
|
||||
* appearance and fades out for {@link EXIT_MS} before unmounting.
|
||||
* @param props.state - visible outage, retry-attempt, or recovered state.
|
||||
* @param props.disconnectedLabel - localized outage text.
|
||||
* @param props.reconnectLabel - localized action text shown on hover or focus.
|
||||
* @param props.disconnectedLabel - localized outage text naming the retry action.
|
||||
* @param props.connectingLabel - localized retry text followed by the attempt dots.
|
||||
* @param props.recoveredLabel - localized recovery confirmation.
|
||||
* @param props.reconnectActionLabel - accessible label for the outage action.
|
||||
@@ -22,7 +28,6 @@ export type ConnectionIndicatorState =
|
||||
export function ConnectionIndicator({
|
||||
state,
|
||||
disconnectedLabel,
|
||||
reconnectLabel,
|
||||
connectingLabel,
|
||||
recoveredLabel,
|
||||
reconnectActionLabel,
|
||||
@@ -31,63 +36,66 @@ export function ConnectionIndicator({
|
||||
}: {
|
||||
state: ConnectionIndicatorState | undefined
|
||||
disconnectedLabel: string
|
||||
reconnectLabel: string
|
||||
connectingLabel: string
|
||||
recoveredLabel: string
|
||||
reconnectActionLabel: string
|
||||
restartActionLabel: string
|
||||
onReconnect: () => void
|
||||
}) {
|
||||
if (state === undefined) return null
|
||||
const sizeLabels = (
|
||||
<>
|
||||
<span className={css.sizeLabel} aria-hidden="true">{disconnectedLabel}</span>
|
||||
<span className={css.sizeLabel} aria-hidden="true">{reconnectLabel}</span>
|
||||
<span className={css.sizeLabel} aria-hidden="true">
|
||||
{connectingLabel}<span className={css.dots}>...</span>
|
||||
</span>
|
||||
<span className={css.sizeLabel} aria-hidden="true">{recoveredLabel}</span>
|
||||
</>
|
||||
)
|
||||
if (state === 'recovered') {
|
||||
const [rendered, setRendered] = useState(state)
|
||||
const leaving = state === undefined && rendered !== undefined
|
||||
useEffect(() => {
|
||||
if (state !== undefined) {
|
||||
setRendered(state)
|
||||
return
|
||||
}
|
||||
if (rendered === undefined) return
|
||||
const timeout = window.setTimeout(() => { setRendered(undefined) }, EXIT_MS)
|
||||
return () => { window.clearTimeout(timeout) }
|
||||
}, [state, rendered])
|
||||
|
||||
if (rendered === undefined) return null
|
||||
const leavingClass = leaving ? ` ${css.leaving}` : ''
|
||||
if (rendered === 'recovered') {
|
||||
return (
|
||||
<div className={`${css.indicator} ${css.success}`} role="status" aria-label={recoveredLabel}>
|
||||
<div
|
||||
className={`${css.indicator} ${css.success}${leavingClass}`}
|
||||
role="status"
|
||||
aria-label={recoveredLabel}
|
||||
>
|
||||
<span className={css.icon} aria-hidden="true"><IconCheckOutline16 size={14} /></span>
|
||||
<span className={css.label}>
|
||||
{sizeLabels}
|
||||
<span className={css.stateLabel}>{recoveredLabel}</span>
|
||||
</span>
|
||||
<span className={css.label}>{recoveredLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const connecting = state === 'connecting'
|
||||
const connecting = rendered === 'connecting'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.indicator} ${css.warning}`}
|
||||
data-phase={state}
|
||||
className={`${css.indicator} ${css.warning}${leavingClass}`}
|
||||
data-phase={rendered}
|
||||
aria-label={connecting ? restartActionLabel : reconnectActionLabel}
|
||||
onClick={onReconnect}
|
||||
>
|
||||
<span className={css.icon} aria-hidden="true"><IconWarningOutline16 size={14} /></span>
|
||||
<span className={css.icon} aria-hidden="true">
|
||||
{connecting
|
||||
? <IconLoadingOutline16 size={14} className={css.spinner} />
|
||||
: <IconRefreshOutline14 size={14} />}
|
||||
</span>
|
||||
<span className={css.label}>
|
||||
{sizeLabels}
|
||||
<span className={css.stateLabel}>
|
||||
{connecting
|
||||
? (
|
||||
<>
|
||||
{connectingLabel}
|
||||
<span className={css.dots} aria-hidden="true">
|
||||
<span>.</span>
|
||||
<span className={css.secondDot}>.</span>
|
||||
<span className={css.thirdDot}>.</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
: disconnectedLabel}
|
||||
</span>
|
||||
<span className={css.hoverLabel}>{reconnectLabel}</span>
|
||||
{connecting
|
||||
? (
|
||||
<>
|
||||
{connectingLabel}
|
||||
<span className={css.dots} aria-hidden="true">
|
||||
<span>.</span>
|
||||
<span className={css.secondDot}>.</span>
|
||||
<span className={css.thirdDot}>.</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
: disconnectedLabel}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -462,8 +462,7 @@ describe('ConnectionIndicator', () => {
|
||||
it('renders outage, attempt progress, and recovered states without a native tooltip', () => {
|
||||
const reconnect = vi.fn()
|
||||
const labels = {
|
||||
disconnectedLabel: 'Disconnected',
|
||||
reconnectLabel: 'Reconnect',
|
||||
disconnectedLabel: 'Disconnected, retry',
|
||||
connectingLabel: 'Connecting',
|
||||
recoveredLabel: 'Connected',
|
||||
reconnectActionLabel: 'Disconnected, reconnect now',
|
||||
@@ -476,8 +475,7 @@ describe('ConnectionIndicator', () => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
rerender(<ConnectionIndicator state="disconnected" {...labels} />)
|
||||
const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
|
||||
expect(indicator.textContent).toContain('Disconnected')
|
||||
expect(indicator.textContent).toContain('Reconnect')
|
||||
expect(indicator.textContent).toContain('Disconnected, retry')
|
||||
expect(indicator.hasAttribute('title')).toBe(false)
|
||||
expect(indicator.querySelector('svg')).toBeTruthy()
|
||||
fireEvent.click(indicator)
|
||||
@@ -491,4 +489,27 @@ describe('ConnectionIndicator', () => {
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('fades out for the exit duration before unmounting', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const labels = {
|
||||
disconnectedLabel: 'Disconnected, retry',
|
||||
connectingLabel: 'Connecting',
|
||||
recoveredLabel: 'Connected',
|
||||
reconnectActionLabel: 'Disconnected, reconnect now',
|
||||
restartActionLabel: 'Connecting, restart now',
|
||||
onReconnect: vi.fn(),
|
||||
}
|
||||
const { container, rerender } = render(
|
||||
<ConnectionIndicator state="disconnected" {...labels} />,
|
||||
)
|
||||
rerender(<ConnectionIndicator state={undefined} {...labels} />)
|
||||
expect(screen.getByRole('button', { name: 'Disconnected, reconnect now' })).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(container.firstChild).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
|
||||
README.md: 845d9c48dce2264d478f0ac854ef85a480828a14
|
||||
README.zh.md: 814082f94156e44b45e015e67acb7a281ba2e7e9
|
||||
README.md: af68c42ad111f74e68037436e18a5be57195a64b
|
||||
README.zh.md: f929df66a13cde7beb63721922f2a3f4aa90ba43
|
||||
@@ -25,7 +25,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
|
||||
Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows a spinner beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
|
||||
|
||||
### The General section
|
||||
|
||||
@@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may
|
||||
|
||||
### Connection recovery
|
||||
|
||||
The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer.
|
||||
The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the 800ms minimum-visible hold for the connecting state and the two-second recovered-state timer, which starts when the recovered pill becomes visible after the hold.
|
||||
|
||||
### Document availability
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
|
||||
用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示 spinner 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
|
||||
|
||||
### 「通用」分区
|
||||
|
||||
@@ -55,7 +55,7 @@ kind: "package-reference"
|
||||
|
||||
### 连接恢复
|
||||
|
||||
外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有。
|
||||
外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;连接中状态的 800ms 最短可见驻留与 2 秒恢复确认计时器归外壳所有;恢复计时从驻留结束、恢复药丸实际可见时开始。
|
||||
|
||||
### 文档可用性
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ import css from './SettingsRoot.module.css'
|
||||
|
||||
const RECOVERY_CONFIRMATION_MS = 2_000
|
||||
|
||||
/** Minimum visible time for the connecting pill; shorter attempts read as flicker. */
|
||||
const CONNECTING_MIN_VISIBLE_MS = 800
|
||||
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
function navIcon(id: string) {
|
||||
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
|
||||
@@ -113,6 +116,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [showRecovery, setShowRecovery] = useState(false)
|
||||
const [holdConnecting, setHoldConnecting] = useState(false)
|
||||
const connectingShownAt = useRef<number | undefined>(undefined)
|
||||
const triggerButton = useRef<HTMLButtonElement | null>(null)
|
||||
const wasOpen = useRef(open)
|
||||
const close = useCallback(() => {
|
||||
@@ -157,8 +162,32 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
}
|
||||
if (previous !== 'disconnected' && previous !== 'connecting') return
|
||||
setShowRecovery(true)
|
||||
}, [connectionState])
|
||||
|
||||
// The confirmation window starts when the recovered pill becomes visible,
|
||||
// which the connecting minimum-visible hold can delay past the transition.
|
||||
useLayoutEffect(() => {
|
||||
if (!showRecovery || holdConnecting) return
|
||||
const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS)
|
||||
return () => { window.clearTimeout(timeout) }
|
||||
}, [showRecovery, holdConnecting])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (connectionState === 'connecting') {
|
||||
connectingShownAt.current = Date.now()
|
||||
return
|
||||
}
|
||||
const shownAt = connectingShownAt.current
|
||||
if (shownAt === undefined) return
|
||||
connectingShownAt.current = undefined
|
||||
const remaining = CONNECTING_MIN_VISIBLE_MS - (Date.now() - shownAt)
|
||||
if (remaining <= 0) return
|
||||
setHoldConnecting(true)
|
||||
const timeout = window.setTimeout(() => { setHoldConnecting(false) }, remaining)
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
setHoldConnecting(false)
|
||||
}
|
||||
}, [connectionState])
|
||||
|
||||
const completeOnboardingStep = useCallback((id: string) => {
|
||||
@@ -169,10 +198,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
}, [])
|
||||
|
||||
let connectionIndicator: ConnectionIndicatorState | undefined
|
||||
if (connectionState === 'disconnected') {
|
||||
connectionIndicator = 'disconnected'
|
||||
} else if (connectionState === 'connecting') {
|
||||
if (connectionState === 'connecting' || holdConnecting) {
|
||||
connectionIndicator = 'connecting'
|
||||
} else if (connectionState === 'disconnected') {
|
||||
connectionIndicator = 'disconnected'
|
||||
} else if (showRecovery) {
|
||||
connectionIndicator = 'recovered'
|
||||
}
|
||||
@@ -194,7 +223,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
<ConnectionIndicator
|
||||
state={wide ? connectionIndicator : undefined}
|
||||
disconnectedLabel={t('connection.error')}
|
||||
reconnectLabel={t('connection.retry')}
|
||||
connectingLabel={t('connection.connecting')}
|
||||
recoveredLabel={t('connection.connected')}
|
||||
reconnectActionLabel={t('connection.reconnect')}
|
||||
|
||||
@@ -8,12 +8,11 @@ export const zh = {
|
||||
'openDocument': '打开配置文件',
|
||||
'openDocument.error': '无法打开配置文件',
|
||||
'general.nav': '通用设置',
|
||||
'connection.error': '连接异常',
|
||||
'connection.retry': '立即重连',
|
||||
'connection.connecting': '自动重连中',
|
||||
'connection.error': '连接异常,刷新重试',
|
||||
'connection.connecting': '重新连接中',
|
||||
'connection.connected': '连接成功',
|
||||
'connection.reconnect': '连接异常,点击立即重连',
|
||||
'connection.restart': '连接中断,正在自动重试,点击立即重连',
|
||||
'connection.restart': '连接中断,正在重试,点击立即重连',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings namespace key union. */
|
||||
@@ -28,9 +27,8 @@ export const en = {
|
||||
'openDocument.error': 'Could not open configuration file',
|
||||
'general.nav': 'General',
|
||||
'connection.error': 'Disconnected',
|
||||
'connection.retry': 'Reconnect now',
|
||||
'connection.connecting': 'Reconnecting',
|
||||
'connection.connected': 'Connected',
|
||||
'connection.reconnect': 'Disconnected, reconnect now',
|
||||
'connection.restart': 'Reconnecting automatically, reconnect now',
|
||||
'connection.restart': 'Reconnecting, reconnect now',
|
||||
} satisfies Record<SettingsKey, string>
|
||||
@@ -118,8 +118,8 @@ describe('ui-settings-general apply', () => {
|
||||
settings.mutate.mockResolvedValueOnce(ok(english))
|
||||
const t = c.ctx.locale.bind(NS)
|
||||
expect(t('title')).toBe('设置')
|
||||
expect(t('connection.error')).toBe('连接异常')
|
||||
expect(t('connection.connecting')).toBe('自动重连中')
|
||||
expect(t('connection.error')).toBe('连接异常,刷新重试')
|
||||
expect(t('connection.connecting')).toBe('重新连接中')
|
||||
expect(t('connection.connected')).toBe('连接成功')
|
||||
c.ctx.locale.setLocale('en')
|
||||
expect(t('close')).toBe('Close')
|
||||
|
||||
@@ -162,14 +162,57 @@ describe('SettingsRoot trigger', () => {
|
||||
expect(mounted.reconnect).toHaveBeenCalledOnce()
|
||||
|
||||
mounted.setConnectionState('connecting')
|
||||
expect(screen.getByRole('button', { name: 'Reconnecting automatically, reconnect now' }).textContent)
|
||||
expect(screen.getByRole('button', { name: 'Reconnecting, reconnect now' }).textContent)
|
||||
.toContain('Reconnecting...')
|
||||
|
||||
// An attempt that resolves instantly still shows the connecting pill for
|
||||
// its 800ms minimum before the confirmation replaces it.
|
||||
mounted.setConnectionState('connected')
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(800) })
|
||||
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
|
||||
// The confirmation window is measured from visibility, not the transition.
|
||||
act(() => { vi.advanceTimersByTime(1_999) })
|
||||
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
|
||||
// The confirmation window closes at 2s, then the pill fades for 150ms.
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the attempt label steady through the hold and confirms for the full window', () => {
|
||||
vi.useFakeTimers()
|
||||
const mounted = mount({ dictionary: zh })
|
||||
mounted.setConnectionState('connecting')
|
||||
const attempt = screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' })
|
||||
expect(attempt.textContent).toContain('重新连接中')
|
||||
fireEvent.click(attempt)
|
||||
expect(mounted.reconnect).toHaveBeenCalledOnce()
|
||||
expect(attempt.textContent).toContain('重新连接中')
|
||||
// An attempt that resolves mid-hold keeps its label until the hold ends.
|
||||
act(() => { vi.advanceTimersByTime(100) })
|
||||
mounted.setConnectionState('connected')
|
||||
expect(screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }).textContent)
|
||||
.toContain('重新连接中')
|
||||
act(() => { vi.advanceTimersByTime(700) })
|
||||
expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
|
||||
// The full two-second confirmation follows the delayed appearance.
|
||||
act(() => { vi.advanceTimersByTime(1_999) })
|
||||
expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips the hold when the attempt already stayed visible long enough', () => {
|
||||
vi.useFakeTimers()
|
||||
const mounted = mount()
|
||||
mounted.setConnectionState('connecting')
|
||||
act(() => { vi.advanceTimersByTime(800) })
|
||||
mounted.setConnectionState('connected')
|
||||
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
|
||||
README.md: 283761d17f857c9142a59ee3b333081df84ecd9c
|
||||
README.zh.md: 61029555adfc3ee14ccb5b083322d7ae5dbedfee
|
||||
README.md: f1314b1db9ee231b2f67777491ac57aefc5ea85a
|
||||
README.zh.md: 17861766c52333e90947a6c643c7df8cab4992b4
|
||||
@@ -37,6 +37,8 @@ The primary field on an editor card is a single **API key** input — the page n
|
||||
|
||||
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits.
|
||||
|
||||
The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration.
|
||||
|
||||
### Adding and deleting providers
|
||||
|
||||
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
|
||||
@@ -69,7 +71,7 @@ Each settings write carries the card's current `revision`, so a concurrent write
|
||||
|
||||
### Onboarding coordinator
|
||||
|
||||
The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed.
|
||||
The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step targets `deepseek-official` in `llm-deepseek` and renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ kind: "package-reference"
|
||||
|
||||
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。
|
||||
|
||||
`llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。
|
||||
|
||||
### 新增与删除提供方
|
||||
|
||||
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
|
||||
@@ -69,7 +71,7 @@ kind: "package-reference"
|
||||
|
||||
### 引导协调器
|
||||
|
||||
声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
|
||||
声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@ import styles from './ModelsSection.module.css'
|
||||
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
||||
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
|
||||
|
||||
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
|
||||
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
|
||||
/** Props of {@link ProviderEditor}. */
|
||||
export interface ProviderEditorProps {
|
||||
@@ -414,14 +413,16 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
type="text"
|
||||
value={stringAt(draft, 'baseURL') ?? ''}
|
||||
placeholder={family === 'deepseek'
|
||||
? DEEPSEEK_PUBLIC_BASE_URL
|
||||
? t(stringAt(fallback, 'protocol') === 'messages' ? 'deepSeekMessagesBaseUrl' : 'deepSeekChatBaseUrl')
|
||||
: stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
|
||||
aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined}
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setField('baseURL', event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
/>
|
||||
{family === 'deepseek' ? <span id={`${props.provider}-endpoint-hint`} className={styles['advancedHint']}>{t('deepSeekEndpointHint')}</span> : null}
|
||||
</div>
|
||||
{/* The protocol sits beside the endpoint it describes, as it does
|
||||
on the create card. */}
|
||||
|
||||
@@ -35,6 +35,9 @@ export const en = {
|
||||
customized: 'Customized settings',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlDefault: 'Provider default',
|
||||
deepSeekChatBaseUrl: 'https://api.deepseek.com',
|
||||
deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic',
|
||||
deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.',
|
||||
models: 'Models',
|
||||
modelsInherited: 'Using the adapter defaults',
|
||||
modelsCustomized: 'Customized model catalog',
|
||||
@@ -142,6 +145,9 @@ export const zh: { [Key in keyof typeof en]: string } = {
|
||||
customized: '自定义设置',
|
||||
baseUrl: 'API 地址',
|
||||
baseUrlDefault: '提供方默认',
|
||||
deepSeekChatBaseUrl: 'https://api.deepseek.com',
|
||||
deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic',
|
||||
deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。',
|
||||
models: '模型目录',
|
||||
modelsInherited: '正在使用适配器默认模型',
|
||||
modelsCustomized: '已自定义模型目录',
|
||||
|
||||
@@ -665,6 +665,51 @@ describe('ModelsSection', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('edits the shared DeepSeek card while preserving the YAML protocol selection', async () => {
|
||||
const namespace: SettingsNamespaceView = {
|
||||
...wireNamespaces()[0]!,
|
||||
ns: 'llm-deepseek',
|
||||
value: { protocol: 'messages', apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS },
|
||||
user: {},
|
||||
}
|
||||
const { face, mutate, set } = scriptedFace({
|
||||
mutate: vi.fn(() => Promise.resolve(remoteOk(namespace))),
|
||||
})
|
||||
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
||||
render(<ProviderEditor
|
||||
provider="deepseek-official"
|
||||
displayName="DeepSeek"
|
||||
namespace={namespace}
|
||||
schema={settingsSchema}
|
||||
settingsPath={[]}
|
||||
operations={operationsWith(face)}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
onClose={vi.fn()}
|
||||
/>)
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder)
|
||||
.toBe('https://api.deepseek.com/anthropic')
|
||||
expect(screen.queryByLabelText(en.customApi)).toBeNull()
|
||||
expect(screen.getByText(en.deepSeekEndpointHint)).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-messages-test' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://messages.example/anthropic' } })
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Messages Flash' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-messages-test') })
|
||||
expect(mutate.mock.calls).toEqual([[
|
||||
'llm-deepseek',
|
||||
[
|
||||
{ op: 'set', path: ['baseURL'], value: 'https://messages.example/anthropic' },
|
||||
{ op: 'set', path: ['models'], value: [
|
||||
{ ...DEFAULT_DEEPSEEK_MODELS[0], name: 'Messages Flash' },
|
||||
DEFAULT_DEEPSEEK_MODELS[1],
|
||||
] },
|
||||
],
|
||||
0,
|
||||
]])
|
||||
})
|
||||
|
||||
it('rejects duplicate DeepSeek model ids before writing', async () => {
|
||||
const { mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md
|
||||
README.md: 9e5997eb2ebe69b4b2d49b57a61b0bd36ec41053
|
||||
README.zh.md: 44a73607f95e7859b344034b5abaa2c386619083
|
||||
README.md: 5a31c688d7f7ea9a9ad10877628a333d797ac06d
|
||||
README.zh.md: 4db6cfa9fdead65c8e1c567981d0c16d92e9cb1e
|
||||
@@ -29,7 +29,7 @@ When the agent asks a question, the composer becomes the question surface: answe
|
||||
|
||||
### Answering
|
||||
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
### The plan-review card
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 作答
|
||||
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
### plan-review 卡片
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const zh = {
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.skip': '跳过',
|
||||
'action.next': '下一题',
|
||||
'plan.header': '计划待审',
|
||||
'plan.approve': '确认执行',
|
||||
@@ -33,7 +33,7 @@ export const en = {
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.skip': 'Skip',
|
||||
'action.next': 'Next',
|
||||
'plan.header': 'Plan review',
|
||||
'plan.approve': 'Approve',
|
||||
|
||||
@@ -247,9 +247,9 @@ describe('QuestionComposer', () => {
|
||||
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳过' }))
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳过' }))
|
||||
|
||||
expect(answer).toHaveBeenCalledWith(answerBatch([
|
||||
{ id: 'profile', selected: ['研究潜力型'] },
|
||||
@@ -346,7 +346,7 @@ describe('QuestionComposer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('第一次取消失败')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||
@@ -387,7 +387,7 @@ describe('QuestionComposer', () => {
|
||||
const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }])
|
||||
render(<QuestionComposer matched={carrier} {...kit} t={seatOver(en, commonEn)} />)
|
||||
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Skip' })).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/web/README.md
|
||||
README.md: 6bc0c4f241d1afb28825cadc19f89b7f54d4b637
|
||||
README.zh.md: e2d7ab81e82f1b5b1b93b95e4ed117a80f897a8e
|
||||
README.md: 7bdcec9e2a964b746cbd808497f9222e2b4150fa
|
||||
README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337
|
||||
@@ -35,7 +35,7 @@ Boot runs in two stages: the module stage adopts the parser-loaded bootstrap bat
|
||||
|
||||
### The boot page
|
||||
|
||||
The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import error, or state) instead of a blank page.
|
||||
The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import failure, or state) instead of a blank page. The console contains the original import error.
|
||||
|
||||
### The shared module table
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ kind: "package-library"
|
||||
|
||||
### 启动页
|
||||
|
||||
启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入错误或状态),而不是白屏。
|
||||
启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入失败或状态),而不是白屏。控制台包含原始导入错误。
|
||||
|
||||
### 共享模块表
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ export interface ClientBootOptions {
|
||||
/**
|
||||
* Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
|
||||
* `loader.create({ name })` per manifest row, `loader.await()`, then
|
||||
* {@link assertEntriesActive}. A row whose module cannot be imported rejects
|
||||
* `loader.create`, so that import error propagates from here as-is.
|
||||
* {@link assertEntriesActive}. A row whose module cannot be imported is marked
|
||||
* failed; the Loader logs its import error and the audit rejects startup.
|
||||
* @param options - context, module system, manifest, optional progress sink.
|
||||
* @returns resolves after every entry is active; rejects with the audit report otherwise.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createClientModuleSystem, parseBootManifest,
|
||||
type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
|
||||
} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
|
||||
import { FIBER_STATE } from '../src/loader-status.ts'
|
||||
|
||||
@@ -77,17 +77,21 @@ describe('bootClient', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => {
|
||||
it('reports and logs an import failure for a row that is neither seeded nor a graph row', async () => {
|
||||
const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
|
||||
const manifest = parseBootManifest(graphOf(['ghost']))
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
|
||||
onTestFinished(() => { error.mockRestore() })
|
||||
const sink = stateSink()
|
||||
|
||||
await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
|
||||
/failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/,
|
||||
'web boot: 1 entry did not activate\nghost: import failed (see console for the import error)',
|
||||
)
|
||||
expect(sink.states.get('ghost')).toEqual(['loading'])
|
||||
await ctx.fiber.dispose()
|
||||
expect(sink.states.get('ghost')).toEqual(['loading', 'failed'])
|
||||
expect(error).toHaveBeenCalledOnce()
|
||||
expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('client-modules: cannot resolve'))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
|
||||
README.md: 7e4826c4fb51873db02fd80eca632c3d1dd8d550
|
||||
README.zh.md: 30f2a43908513f5fe1e7169bff7ee95c9461ea5d
|
||||
README.md: a76ae5c66f0801cb85cd8cf22d0c3ebbc68e65b8
|
||||
README.zh.md: 98c6e733399c954c5bbc56b905aefcc6b080381d
|
||||
@@ -63,6 +63,8 @@ ctx.systemPrompt.section({
|
||||
})
|
||||
```
|
||||
|
||||
Set `interpolate: false` on a section to preserve its text literally, including `{{…}}` groups in generated tool documentation. Other sections interpolate variables by default.
|
||||
|
||||
### Contribute a prompt variable
|
||||
|
||||
Variables are referenced from section text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `model` and `cwd`; any plugin can register the facts it owns.
|
||||
@@ -102,7 +104,7 @@ The package is a registry plus a cooperative assembly pipeline. One `assemble()`
|
||||
|
||||
### Assembly and rendering
|
||||
|
||||
Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
|
||||
Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
|
||||
|
||||
### Scoping
|
||||
|
||||
@@ -170,7 +172,7 @@ Prefix-stable while the visible schema set, rendering, and order are unchanged.
|
||||
These limits define when prompt assembly needs special care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona prefix and suffix defaults, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API.
|
||||
- **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one.
|
||||
- **No inline escape syntax in interpolated text** — use `interpolate: false` when a whole section must preserve literal braces.
|
||||
- **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load.
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ ctx.systemPrompt.section({
|
||||
})
|
||||
```
|
||||
|
||||
在段上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档中的 `{{…}}` 组。其他段默认执行变量插值。
|
||||
|
||||
### 贡献提示词变量
|
||||
|
||||
变量在段文本中以 `{{name}}` 引用,并在每次组装时解析;带作用域变量会为该 agent 遮蔽同名全局变量。循环提供 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实。
|
||||
@@ -102,7 +104,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
|
||||
|
||||
### 组装与渲染
|
||||
|
||||
组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
|
||||
组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
|
||||
|
||||
### 作用域
|
||||
|
||||
@@ -170,7 +172,7 @@ schema token 在每次请求中重复。限制工具会为该 agent 移除其全
|
||||
这些限制说明提示词组装何时需要特别留意。它们是当前包约束,不是待办事项清单。
|
||||
|
||||
- **部署方编写的提示词文本只来自配置/组合**:此插件拥有全局 persona 前缀与后缀默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。
|
||||
- **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现。
|
||||
- **插值文本不支持行内转义语法**:整段需要保留字面花括号时,使用 `interpolate: false`。
|
||||
- **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。
|
||||
|
||||
|
||||
|
||||
@@ -61,9 +61,11 @@ export interface PromptSection {
|
||||
/**
|
||||
* Static text or a provider evaluated at each assembly with that assembly's
|
||||
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
|
||||
* interpolated later, by {@link renderPrompt}.
|
||||
* interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
|
||||
*/
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
|
||||
readonly interpolate?: boolean
|
||||
/**
|
||||
* Treat this contribution as the complete system prompt. Assembly still
|
||||
* runs the cooperative waterfall so tools, contexts, and variables can be
|
||||
@@ -89,6 +91,8 @@ export interface AssembledSection {
|
||||
name: string
|
||||
/** The resolved (but not yet interpolated) section text. */
|
||||
text: string
|
||||
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
|
||||
interpolate?: boolean
|
||||
}
|
||||
|
||||
/** One resolved dynamic context contribution. */
|
||||
@@ -264,7 +268,8 @@ export interface Config {
|
||||
|
||||
/**
|
||||
* Interpolate strict `{{variable}}` references, drop empty sections, and join
|
||||
* the rest with blank lines. Malformed, unknown, or undefined references throw;
|
||||
* the rest with blank lines. Sections with `interpolate: false` retain literal
|
||||
* text. Malformed, unknown, or undefined references in other sections throw;
|
||||
* a lone `{{` without any later `}}` is literal prose, and substituted values
|
||||
* are not scanned again.
|
||||
* @param assembly - the assembly whose sections and variables to render.
|
||||
@@ -272,7 +277,7 @@ export interface Config {
|
||||
*/
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
.map(section => interpolate(section, assembly.variables, 'section'))
|
||||
.map(section => section.interpolate === false ? section.text : interpolate(section, assembly.variables, 'section'))
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -597,6 +602,7 @@ export class SystemPrompt extends Service {
|
||||
const assembled = {
|
||||
name: section.name,
|
||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||
...section.interpolate !== undefined ? { interpolate: section.interpolate } : {},
|
||||
}
|
||||
if (section.complete === true) completeSection = { ...assembled }
|
||||
return assembled
|
||||
|
||||
@@ -579,6 +579,25 @@ describe('SystemPrompt', () => {
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[false, true],
|
||||
[true, false],
|
||||
[true, true],
|
||||
])('preserves literal section text with complete=%s and dynamic=%s', async (complete, dynamic) => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SystemPrompt, { includeHarnessIdentity: false, personaPrefix: '{{model}}' })
|
||||
ctx.systemPrompt.variable('model', () => 'actual-model')
|
||||
const text = '{{item}} {{model}} {{ model }} {{nested{{item}}}}'
|
||||
ctx.systemPrompt.section({ name: 'literal', order: 1, text: dynamic ? () => text : text, interpolate: false, complete })
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble()))
|
||||
.toBe(complete ? text : `actual-model\n\n${text}`)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a waterfall listener add or override variables before render', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
|
||||
README.md: a62e89c1bde0b96d5ba6b0e7c8f27d2af87f4340
|
||||
README.zh.md: d10ce3913cc9eb24e417f61c5b9f081a40b50cc3
|
||||
README.md: 17e2eb05bac152c75f7a906bf6e15f26517d38dc
|
||||
README.zh.md: cd08e9282e2ac6e75c1694d9989a4079d65985b5
|
||||
@@ -170,7 +170,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
|
||||
|
||||
#### What the model sees
|
||||
|
||||
PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
|
||||
PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
|
||||
|
||||
##### TypeScript PTC mode SDK instructions with bash
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ ctx.tools.register(defineTool({
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
|
||||
PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
|
||||
|
||||
##### 带 bash 的 TypeScript PTC mode SDK 说明
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
@@ -844,7 +844,7 @@ export class ToolRuntime extends Service {
|
||||
* `both` renders empty: native calls do execute there, so the rule is false.
|
||||
* @returns the section registration.
|
||||
*/
|
||||
private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
|
||||
private collapseSection(): PromptSection {
|
||||
return {
|
||||
name: 'tools:ptc-only',
|
||||
order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'),
|
||||
@@ -864,10 +864,11 @@ export class ToolRuntime extends Service {
|
||||
* dropped from the rendered prompt.
|
||||
* @returns the section registration.
|
||||
*/
|
||||
private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
|
||||
private sdkSection(): PromptSection {
|
||||
return {
|
||||
name: 'tools:sdk',
|
||||
order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'),
|
||||
interpolate: false,
|
||||
// Regenerate from the calling scope's visible tools in stable order.
|
||||
text: (context) => {
|
||||
const mode = this.modeFor(context.scope)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Tool documentation containing literal template syntax for recorded PTC replay. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'literal-sdk'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'template_echo',
|
||||
description: 'Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution.',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute: args => Promise.resolve(args.text),
|
||||
}))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
@@ -117,6 +117,38 @@ async function runCode(
|
||||
}
|
||||
|
||||
describe('mode-aware wire contribution', () => {
|
||||
it.each([
|
||||
{ mode: 'ptc', language: 'typescript' },
|
||||
{ mode: 'both', language: 'typescript' },
|
||||
{ mode: 'ptc', language: 'python' },
|
||||
{ mode: 'both', language: 'python' },
|
||||
] as const)('preserves literal braces in the $language SDK under $mode', async ({ mode, language }) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode, runtime: { language } })
|
||||
try {
|
||||
systemPrompt.variable('model', () => 'actual-model')
|
||||
const description = 'Expand {{item}} with {{model}} or {{ model }}.'
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'template',
|
||||
description,
|
||||
parameters: { value: { type: 'string', description, enum: ['{{item}}', '{{model}}'], required: true } },
|
||||
output: {
|
||||
schema: { type: 'string', enum: ['{{item}}', '{{model}}'] },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute: args => Promise.resolve(args.value),
|
||||
}))
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
|
||||
expect(sdk).toBeDefined()
|
||||
const prompt = renderPrompt(assembly)
|
||||
expect(prompt).toContain(description)
|
||||
expect(prompt).toContain(sdk!.text)
|
||||
expect(prompt).not.toContain('actual-model')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false })
|
||||
registerEcho(ctx)
|
||||
|
||||
@@ -2,16 +2,13 @@ import { EventEmitter } from 'node:events'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/**
|
||||
* A synchronous `proto.write` throw on the fd-3 pipe is the one boot path a real
|
||||
* subprocess cannot be coerced into from a test: the pipe accepts queued bytes
|
||||
* until the kernel buffer fills, and a same-tick EPIPE needs fd 3 already closed
|
||||
* before the first write. `spawn` is mocked so fd 3 throws on the boot frame,
|
||||
* which is exactly the branch that regressed. The mock is confined to this file
|
||||
* so the real-subprocess suite in runtime.spec.ts is untouched.
|
||||
* Mocked subprocess pipes control synchronous write failures and backpressure
|
||||
* transitions independently of kernel buffering. The real-subprocess suite
|
||||
* remains in runtime.spec.ts.
|
||||
*/
|
||||
const { execFileSyncMock, spawnMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), spawnMock: vi.fn() }))
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
@@ -128,7 +125,67 @@ function fakeChildBackpressuredThenDestroyed(): { child: EventEmitter; proto: Pa
|
||||
return { child, proto }
|
||||
}
|
||||
|
||||
describe('PythonCodeRuntime — boot-write failure', () => {
|
||||
describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
it('preserves pending replies across compaction while the pipe stays backpressured', async () => {
|
||||
const spawned = Promise.withResolvers<undefined>()
|
||||
let written = Promise.withResolvers<undefined>()
|
||||
const replies: unknown[] = []
|
||||
const proto = new PassThrough()
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const child = Object.assign(new EventEmitter(), { stdout, stderr, stdio: [stdin, stdout, stderr, proto] })
|
||||
proto.write = (chunk: unknown) => {
|
||||
const frame = JSON.parse(String(chunk)) as { type: string }
|
||||
if (frame.type !== 'reply') return true
|
||||
replies.push(frame)
|
||||
written.resolve(undefined)
|
||||
return false
|
||||
}
|
||||
spawnMock.mockImplementation(() => { spawned.resolve(undefined); return child })
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
const run = runtime.run({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: { echo: async (value: unknown) => value as number } }],
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
await fiber.dispose()
|
||||
await run
|
||||
for (const stream of [stdin, stdout, stderr, proto]) stream.destroy()
|
||||
})
|
||||
const calls = (start: number): void => {
|
||||
proto.emit('data', Buffer.from(Array.from({ length: 512 }, (_, offset) => JSON.stringify({
|
||||
type: 'call', id: start + offset, global: 'tools', name: 'echo', args: start + offset,
|
||||
})).join('\n') + '\n'))
|
||||
}
|
||||
const drainThrough = async (count: number): Promise<void> => {
|
||||
while (replies.length < count) {
|
||||
written = Promise.withResolvers<undefined>()
|
||||
expect(proto.listenerCount('drain')).toBe(1)
|
||||
proto.emit('drain')
|
||||
await written.promise
|
||||
}
|
||||
}
|
||||
await spawned.promise
|
||||
proto.emit('data', Buffer.from('{"type":"boot-ack"}\n'))
|
||||
calls(0)
|
||||
await written.promise
|
||||
await drainThrough(256)
|
||||
calls(512)
|
||||
await drainThrough(768)
|
||||
calls(1024)
|
||||
// Each write remains blocked until this fixture emits drain, keeping
|
||||
// pending replies behind the consumed-prefix compaction at frame 1024.
|
||||
await drainThrough(1536)
|
||||
expect(replies).toEqual(Array.from({ length: 1536 }, (_, id) => ({ type: 'reply', id, ok: true, value: id })))
|
||||
proto.emit('drain')
|
||||
proto.emit('data', Buffer.from('{"type":"done","value":"done"}\n'))
|
||||
expect(await run).toMatchObject({ value: 'done' })
|
||||
expect(proto.listenerCount('drain')).toBe(0)
|
||||
})
|
||||
|
||||
it('force-kills a version probe that exceeds its load-time deadline', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
|
||||
@@ -1494,106 +1494,6 @@ describe('PythonCodeRuntime — programs and bindings', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('bounds an illegal-UTF-8 native residual by its U+FFFD-decoded cost', async () => {
|
||||
// Every 0xFF byte is illegal in any UTF-8 sequence, so `toString('utf8')`
|
||||
// renders each as U+FFFD (3 serialized bytes). `accrueStrayCost` must charge
|
||||
// that 3, not the raw 1: otherwise the newline-free residual grows to a full
|
||||
// budget's worth of RAW bytes before flushing — a ~3x undercount that near a
|
||||
// large maxLogBytes retains hundreds of MiB then expands toward a ~1 GiB peak
|
||||
// in flushStray's concat + toString. Paced single-byte writes (each its own
|
||||
// `data` chunk, like the sealing case) expose the sub-chunk accrual: charged
|
||||
// at 3 the residual crosses a 3072-byte budget after ~1024 bytes and flushes;
|
||||
// charged at 1 it would need ~3072 bytes, so the peak residual triples. The
|
||||
// largest merged buffer is the discriminator.
|
||||
const realConcat = Buffer.concat.bind(Buffer)
|
||||
let maxConcat = 0
|
||||
Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
|
||||
const merged = realConcat(list, total)
|
||||
if (merged.length > maxConcat) maxConcat = merged.length
|
||||
return merged
|
||||
}
|
||||
let result: CodeRunResult
|
||||
try {
|
||||
const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
|
||||
result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
// One byte per chunk on every host: a plain yield lets a loaded
|
||||
// reader coalesce, and the coalesced chunk is what the bound below
|
||||
// measures. The payload stays above the 2048 discriminator, so a
|
||||
// raw-byte undercount still flushes the whole residual at EOF.
|
||||
'for _ in range(3200):',
|
||||
' os.write(1, b"\\xff")',
|
||||
' time.sleep(0.001)',
|
||||
'return None',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
} finally {
|
||||
Buffer.concat = realConcat
|
||||
}
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Charged at 3, the residual flushes around 1024 raw bytes; the largest
|
||||
// merged buffer stays well under 2048. A raw-byte undercount would let it
|
||||
// reach ~3072 before flushing, so 2048 discriminates.
|
||||
expect(maxConcat).toBeLessThan(2048)
|
||||
// The paced payload costs ~3.2s deterministically, which is above the
|
||||
// 5000ms default the local unit entry grants, so the case carries its own
|
||||
// bound instead of relying on the lane to widen it.
|
||||
}, 20_000)
|
||||
|
||||
it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => {
|
||||
// A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte
|
||||
// lead plus two 0x80–0xBF continuations) but ILLEGAL: `toString('utf8')`
|
||||
// renders each of the three bytes as its own U+FFFD (serialized cost 9), not
|
||||
// one width-3 character. The newline-free flush trigger weighs the residual
|
||||
// through `accrueStrayCost`, which must validate each lead's
|
||||
// first-continuation range (ED excludes A0–BF) and charge the true 9 — else a
|
||||
// CESU flood undercounts 3x and the residual grows toward a full budget's raw
|
||||
// bytes before flushing, the same peak-memory vector as the 0xFF case. The
|
||||
// bytes are written one at a time (each its own `data` chunk, no pipe
|
||||
// coalescing) and `Buffer.concat` is wrapped to measure the peak residual.
|
||||
const realConcat = Buffer.concat.bind(Buffer)
|
||||
let maxConcat = 0
|
||||
Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
|
||||
const merged = realConcat(list, total)
|
||||
if (merged.length > maxConcat) maxConcat = merged.length
|
||||
return merged
|
||||
}
|
||||
let result: CodeRunResult
|
||||
try {
|
||||
const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
|
||||
result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
'seq = (0xed, 0xa0, 0x80)',
|
||||
// 1100 sequences are 3300 raw bytes, past the 3072-byte budget a
|
||||
// raw-byte undercount reaches, so the undercount flushes above the
|
||||
// 2048 discriminator instead of only at EOF.
|
||||
'for _ in range(1100):',
|
||||
' for b in seq:',
|
||||
' os.write(1, bytes((b,)))',
|
||||
' time.sleep(0.001)',
|
||||
'return None',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
} finally {
|
||||
Buffer.concat = realConcat
|
||||
}
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Each 3-byte sequence costs 9 (three U+FFFD), so single-byte-paced the
|
||||
// residual crosses the 3072 budget after ~342 raw bytes and flushes; the
|
||||
// largest merged buffer stays well under 2048. Charging the structural width
|
||||
// 3 would need ~1024 raw bytes, tripling the peak past 2048.
|
||||
expect(maxConcat).toBeLessThan(2048)
|
||||
// The paced payload costs ~3.3s deterministically, which is above the
|
||||
// 5000ms default the local unit entry grants, so the case carries its own
|
||||
// bound instead of relying on the lane to widen it.
|
||||
}, 20_000)
|
||||
|
||||
it('charges a lone surrogate its full six escaped bytes, not three', async () => {
|
||||
// A forged `log` frame carrying `\ud800` escapes materializes lone
|
||||
// surrogates after JSON.parse. `Buffer.byteLength` of U+FFFD is 3, but
|
||||
@@ -5511,54 +5411,6 @@ describe('PythonCodeRuntime — hostile peer', () => {
|
||||
expect(result.value).toContain('duplicate dict key')
|
||||
}, 30_000)
|
||||
|
||||
it('compacts the reply queue mid-drain without dropping pending frames', async () => {
|
||||
// A reply larger than the writable high-water mark makes the FIRST write
|
||||
// return false, suspending the drain loop; the frames queued behind it
|
||||
// push the drain's consumed head past MAX_PENDING_REPLIES, so the resumed
|
||||
// drain compacts the queue mid-run. The child reads fd 3 itself (blocking
|
||||
// the asyncio pump, so its reads cannot race the host's pushes) and sends
|
||||
// a second wave of calls AFTER reading part of the first wave's replies —
|
||||
// those replies are still pending when the drain's head crosses the
|
||||
// compaction bound, so a compaction that dropped pending frames would
|
||||
// leave the child's reply count short and the read loop spinning to the
|
||||
// wall clock. No fixed sleep: the child's reads pace at the drain's
|
||||
// delivery rate (each write blocks until the child reads), and the host
|
||||
// finishes pushing all of a wave within milliseconds — orders of magnitude
|
||||
// before the head crosses the bound — so the queue is always full at the
|
||||
// splice. Newlines are counted per chunk (each reply carries exactly one),
|
||||
// never by re-scanning the accumulated total, which would be O(n²).
|
||||
const { runtime } = await setup({ maxWallMs: 60_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import os',
|
||||
'frame = b\'{"type":"call","id":%d,"global":"tools","name":"big","args":{}}\\n\'',
|
||||
'for i in range(1024):',
|
||||
' view = memoryview(frame % i)',
|
||||
' while view:',
|
||||
' view = view[os.write(3, view):]',
|
||||
'seen = 0',
|
||||
'while seen < 500:',
|
||||
' chunk = os.read(3, 65536)',
|
||||
' if not chunk:',
|
||||
' break',
|
||||
' seen += chunk.count(b"\\n")',
|
||||
'for i in range(500):',
|
||||
' view = memoryview(frame % (1024 + i))',
|
||||
' while view:',
|
||||
' view = view[os.write(3, view):]',
|
||||
'while seen < 1524:',
|
||||
' chunk = os.read(3, 65536)',
|
||||
' if not chunk:',
|
||||
' break',
|
||||
' seen += chunk.count(b"\\n")',
|
||||
'return "done"',
|
||||
].join('\n'),
|
||||
bindings: [{ global: 'tools', functions: { big: async () => 'x'.repeat(65 * 1024) } }],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
}, 60_000)
|
||||
|
||||
it('bounds a flood of zero-byte log lines through the per-entry separator charge', async () => {
|
||||
// Blank print() lines carry zero content bytes; without the +1 separator
|
||||
// charge they would bypass maxLogBytes entirely and grow the retained
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { logTruncationMarker } from '../src/protocol.ts'
|
||||
|
||||
// Keep the interpreter and pipe lifecycle real; only OS-dependent read sizes
|
||||
// change. Each byte reaches the runtime as its own data event.
|
||||
@@ -54,3 +55,33 @@ it('seals stray fragments without recopying the sealed prefix', async () => {
|
||||
await fiber.dispose()
|
||||
}
|
||||
}, 40_000)
|
||||
|
||||
it.each([
|
||||
{ name: 'illegal UTF-8 bytes', payload: 'b"\\xff" * 3200' },
|
||||
{ name: 'CESU-8 lone surrogates', payload: 'b"\\xed\\xa0\\x80" * 1100' },
|
||||
])('bounds $name by their U+FFFD-decoded cost', async ({ payload }) => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime, { maxLogBytes: 3072, maxWallMs: 30_000 })
|
||||
const realConcat = Buffer.concat.bind(Buffer)
|
||||
let maxConcat = 0
|
||||
const concat = vi.spyOn(Buffer, 'concat').mockImplementation((list, total) => {
|
||||
const merged = realConcat(list, total)
|
||||
maxConcat = Math.max(maxConcat, merged.length)
|
||||
return merged
|
||||
})
|
||||
try {
|
||||
const result = await ctx.codeRuntime.run({
|
||||
program: `import os\nos.write(1, ${payload})\nreturn None`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Each raw byte decodes to U+FFFD (three UTF-8 bytes), so a 3072-byte
|
||||
// budget flushes near 1024 raw bytes. Charging raw or structural widths
|
||||
// instead retains over 2048 bytes before flushing these payloads.
|
||||
expect(maxConcat).toBeLessThan(2048)
|
||||
} finally {
|
||||
concat.mockRestore()
|
||||
await fiber.dispose()
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/cordis-client-runner/README.md
|
||||
README.md: 260d4703bda2560a4254962d7b868221d0328ec8
|
||||
README.zh.md: 9f101f8ddcf4934a9f9ba4cdc4725050d0e8cbfa
|
||||
README.md: 153a8bf3b71d2c4c8edf3423dd0b7004083d1ecc
|
||||
README.zh.md: 207b3d40c56110d734499bb516e41872c53b262a
|
||||
@@ -51,7 +51,7 @@ This section explains the design behind the browser half; the observable behavio
|
||||
|
||||
### Design philosophy
|
||||
|
||||
The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload is entry removal plus factory invalidation plus style removal. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel.
|
||||
The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload removes the entry, waits for its fiber's cleanup, then invalidates its factory and removes its styles. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ kind: "package-reference"
|
||||
|
||||
### 设计理念
|
||||
|
||||
浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载 = 移除 entry + 失效 factory + 撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry-error seam,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。
|
||||
浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载先移除 entry,等待其 fiber 清理完成,再使 factory 失效并撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry-error seam,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -451,7 +451,9 @@ export class DynamicCordisPackageRunner {
|
||||
this.failures.delete(id)
|
||||
// Entry removal disposes the fiber (slot entries and facade effects
|
||||
// cascade); the factory invalidation makes a later re-load legal.
|
||||
await this.env.loader.remove(entryId)
|
||||
const disposal = this.env.loader.resolve(entryId).fiber?.dispose()
|
||||
this.env.loader.remove(entryId)
|
||||
await disposal
|
||||
this.env.modules.invalidate(moduleIdOf(id))
|
||||
styles.dispose()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
@@ -52,7 +52,7 @@ interface Bench {
|
||||
invalidated: string[]
|
||||
removed: string[]
|
||||
created: string[]
|
||||
invoke: ReturnType<typeof vi.fn>
|
||||
invoke: ReturnType<typeof vi.fn<() => Promise<unknown>>>
|
||||
/** Render failures the runner sent upstream, in order. */
|
||||
reported: {
|
||||
agentId: SessionId
|
||||
@@ -103,15 +103,15 @@ async function boot(): Promise<Bench> {
|
||||
return Promise.resolve(entryId)
|
||||
},
|
||||
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
|
||||
remove: async (entryId: string) => {
|
||||
remove: (entryId: string) => {
|
||||
removed.push(entryId)
|
||||
const entry = fibers.get(entryId)
|
||||
fibers.delete(entryId)
|
||||
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
|
||||
void (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
|
||||
},
|
||||
} as unknown as Loader
|
||||
|
||||
const invoke = vi.fn(() => Promise.resolve(null))
|
||||
const invoke = vi.fn<() => Promise<unknown>>(() => Promise.resolve(null))
|
||||
const reported: Bench['reported'] = []
|
||||
// The crash seam is stood in so a test can report an entry failure without a
|
||||
// React render, exactly as the renderer's boundary would; registrations still
|
||||
@@ -298,6 +298,26 @@ describe('failure stages', () => {
|
||||
})
|
||||
|
||||
describe('retract', () => {
|
||||
it('waits for plugin cleanup before invalidating its module factory', async () => {
|
||||
const bench = await boot()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => { release.resolve(undefined) })
|
||||
bench.invoke.mockImplementation(() => {
|
||||
started.resolve(undefined)
|
||||
return release.promise
|
||||
})
|
||||
await bench.runner.load(half({ code: 'return { apply: (ctx) => ctx.effect(() => () => host.call("cleanup", null)) }' }))
|
||||
bench.runner.retract(PLUGIN, RUN)
|
||||
await started.promise
|
||||
await bench.settle()
|
||||
expect(bench.removed).toEqual(['entry-1'])
|
||||
expect(bench.invalidated).toEqual(['dyn/dyn-1'])
|
||||
release.resolve(undefined)
|
||||
await bench.settle()
|
||||
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
|
||||
})
|
||||
|
||||
it('unloads at the named revision', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half())
|
||||
|
||||
@@ -548,9 +548,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
throws: ['an AttachmentError when the durable reference is invalid.'],
|
||||
},
|
||||
{
|
||||
signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>',
|
||||
signature: 'readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise<RequestImageAttachment>',
|
||||
description: 'Generate or read one deterministic model-request version from the stored normalized image.',
|
||||
parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }],
|
||||
parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'target', description: 'route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }],
|
||||
returns: 'request bytes and the cache/upload identity covering every transform input.',
|
||||
},
|
||||
],
|
||||
@@ -3704,7 +3704,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AssembledSection',
|
||||
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
|
||||
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n interpolate?: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssistantMessage',
|
||||
@@ -4363,8 +4363,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';',
|
||||
},
|
||||
{
|
||||
name: 'ImageRequestPolicy',
|
||||
declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n}',
|
||||
name: 'ImageRequestTarget',
|
||||
declaration: 'export interface ImageRequestTarget {\n width: number;\n height: number;\n maxBytes: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ImageVariantId',
|
||||
@@ -4840,7 +4840,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PromptSection',
|
||||
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}',
|
||||
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly interpolate?: boolean;\n readonly complete?: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptSectionOrderName',
|
||||
|
||||
@@ -77,15 +77,20 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
for (const id of [...ids].reverse()) {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// nothing is left to unmount or await then.
|
||||
if (ctx.loader.store[id] === undefined) continue
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after that face quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) continue
|
||||
const disposal = entry.fiber?.dispose()
|
||||
ctx.loader.remove(id)
|
||||
await disposal
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) {
|
||||
ids.push(await ctx.loader.create({ name }))
|
||||
const id = await ctx.loader.create({ name })
|
||||
ids.push(id)
|
||||
const entry = ctx.loader.resolve(id)
|
||||
if (entry.fiber === undefined) throw new Error(`directory-picker-auto: failed to load ${name}`)
|
||||
await entry.fiber.await()
|
||||
}
|
||||
} catch (cause) {
|
||||
// Setup owns the entries it created until it returns the disposer: leaving
|
||||
|
||||
@@ -12,7 +12,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
@@ -205,7 +205,21 @@ describe('real Loader composition', () => {
|
||||
// and the disposer joins the backend's teardown — the service is gone the
|
||||
// moment dispose() settles, with no further loader await.
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await autoEntry.fiber!.dispose()
|
||||
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
const releaseCleanup = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => { releaseCleanup.resolve(undefined) })
|
||||
backendEntry.fiber!.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await releaseCleanup.promise
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = autoEntry.fiber!.dispose().then(() => { disposed = true })
|
||||
await cleanupStarted.promise
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
releaseCleanup.resolve(undefined)
|
||||
await disposal
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
expect(ctx.get('directoryPicker')).toBeUndefined()
|
||||
@@ -245,7 +259,9 @@ describe('real Loader composition', () => {
|
||||
|
||||
it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/)
|
||||
const { ctx } = await loadComposition('127.0.0.1', { failSurface: true })
|
||||
const chooser = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await expect(chooser.fiber!.await()).rejects.toThrow(/directory-picker-auto: failed to load/)
|
||||
|
||||
// Setup owns both entries until it returns its disposer, so a failed surface
|
||||
// must take the mounted backend with it: otherwise a retry collides with the
|
||||
@@ -259,7 +275,7 @@ describe('real Loader composition', () => {
|
||||
const { ctx, configPath } = await loadComposition('127.0.0.1')
|
||||
|
||||
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||
await ctx.loader.remove(backendEntry.id)
|
||||
ctx.loader.remove(backendEntry.id)
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
renameControl.remainingFailures = 1
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('PluginInventoryGateway', () => {
|
||||
fiberPhase: null,
|
||||
})
|
||||
|
||||
await ctx.loader.remove(pendingId)
|
||||
ctx.loader.remove(pendingId)
|
||||
expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Context, FiberState } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import HttpServer, { renderIndexInjections } from '../src/index.ts'
|
||||
@@ -363,14 +363,10 @@ describe('real Loader composition', () => {
|
||||
|
||||
let second: Context | undefined
|
||||
try {
|
||||
let failure: unknown
|
||||
try {
|
||||
await loadComposition(takenPort)
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
second = context
|
||||
expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
|
||||
second = await loadComposition(takenPort)
|
||||
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
|
||||
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
|
||||
await expect(entry?.fiber?.await()).rejects.toThrow('EADDRINUSE')
|
||||
} finally {
|
||||
await second?.fiber.dispose()
|
||||
context = first
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
|
||||
README.md: 41646097f80dfad9cc523595b9d2820c28fde66b
|
||||
README.zh.md: c7a1e40952dff7a804880dcd266114cecb20918f
|
||||
README.md: d70e865b434a09113fc4c68ed84898126872354e
|
||||
README.zh.md: dce5763401c65d47589658ed649f93f3dd43ee13
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "The DeepSeek chat-completions adapter for users and maintainers configuring the deepseek-official route, thinking, and image input."
|
||||
description: "Configure DeepSeek models, thinking, and image input through Chat Completions or Messages under one provider."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this package to stream DeepSeek models through the `deepseek-official` route, including configurable thinking and reasoning effort, image input for vision models, and an advisory model catalog. Endpoint, credentials, catalog, and thinking policy resolve for each request, so valid user-settings changes apply to the next request without restarting the process. Choose it for DeepSeek's official API or an OpenAI-compatible gateway; it can run beside the pi-ai package because they use different route names.
|
||||
Use this package to stream DeepSeek models through `deepseek-official`, choosing Chat Completions or Messages in Cordis YAML. Both protocols share credentials, endpoint configuration, and the model catalog. Valid settings changes apply to subsequent requests; in-flight requests retain their original configuration. Web displays DeepSeek and lets users edit the API address and key. It can run beside the [pi-ai adapter](../llm-pi-ai/README.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -36,6 +36,7 @@ Choose this adapter when the deployment targets DeepSeek's official API, optiona
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
protocol: chat-completions # chat-completions | messages
|
||||
apiKeyEnv: DEEPSEEK_API_KEY # credential reference, resolved per request
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then this default
|
||||
reasoningEffort: high # optional; off | low | high | max
|
||||
@@ -50,8 +51,9 @@ A request selects the route with `provider: deepseek-official`; the model id pas
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `protocol` | `chat-completions` | Choose `chat-completions` or `messages` in Cordis YAML; Web has no protocol selector |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved per request through the credentials seam, then the environment |
|
||||
| `baseURL` | `https://api.deepseek.com` | Endpoint base; `$DEEPSEEK_BASE_URL` wins when set |
|
||||
| `baseURL` | Selected protocol’s official root | Explicit value, then `$DEEPSEEK_BASE_URL`, then the selected protocol default |
|
||||
| `thinking` | `enabled` | Deployment policy; `disabled` locks every request to `off` |
|
||||
| `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` |
|
||||
| `maxTokens` | `256,000` | Per-request output cap; a model's own cap and explicit request values win |
|
||||
@@ -72,15 +74,31 @@ A request selects the route with `provider: deepseek-official`; the model id pas
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-deepseek) is the exhaustive source for every accepted field and its JSDoc.
|
||||
|
||||
<a id="choose-a-protocol"></a>
|
||||
### Choose a protocol
|
||||
|
||||
Switch the existing plugin to Messages with a Cordis patch:
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
protocol: messages
|
||||
baseURL: https://api.deepseek.com/anthropic
|
||||
```
|
||||
|
||||
`protocol` defaults to `chat-completions`, whose official root is `https://api.deepseek.com`; `messages` uses `https://api.deepseek.com/anthropic`. An official default applies only without an explicit `baseURL` or environment override. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. Chat appends `/chat/completions`; Messages appends `/v1/messages`. Apart from trailing slashes, neither infers or removes custom path suffixes such as `/v1`. Both share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
|
||||
|
||||
Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata identifies the Messages format, model, and signatures. Chat requests serialize durable content without those signatures. Invalid Messages replay metadata emits a warning and omits signatures while retaining text and tool history.
|
||||
|
||||
### Streaming with thinking and images
|
||||
|
||||
An image-capable route resolves each durable reference into a deterministic request version under its pixel and byte budgets. `imagePixelBudget` accepts a positive integer or `low`; omission uses 640,000 total pixels, `low` uses 512×512 total pixels, and `imageMaxBytes` defaults to 1 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references.
|
||||
An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references.
|
||||
|
||||
The adapter normally uploads those exact request bytes through the DeepSeek Files API and sends file-id blocks. A failed or timed-out file resolution rebuilds the whole chat request with the same request versions as base64 data URLs; one request never mixes file ids and inline images. Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry.
|
||||
Chat Completions normally uploads those exact request bytes through the DeepSeek Files API and sends file-id blocks. A failed or timed-out file resolution rebuilds the whole chat request with the same request versions as base64 data URLs; one request never mixes file ids and inline images. Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry.
|
||||
|
||||
Files mode bounds retained request versions by `maxRequestFilesBytes` and `maxImagesPerRequest`; inline fallback has its own base64 budget. Both remove an oldest prefix in configured byte or count quanta. Each omitted image gets its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only path. The stepped high-watermark policy avoids rewriting an old request prefix after every new image.
|
||||
|
||||
`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `reasoning_effort`, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text.
|
||||
`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `reasoning_effort` for Chat Completions or `output_config.effort` for Messages, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text.
|
||||
|
||||
### Dynamic configuration
|
||||
|
||||
@@ -88,7 +106,7 @@ Connection facts are re-read once per operation through the optional settings an
|
||||
|
||||
### Provider-specific request fields
|
||||
|
||||
When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the optional incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input.
|
||||
In Chat Completions, when `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the optional incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input.
|
||||
|
||||
### Failures and recovery
|
||||
|
||||
@@ -112,13 +130,13 @@ The plugin is built on one explicit resolve step and one registration fact. `res
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, per-request resolution, settings and credential wiring |
|
||||
| [`src/adapter.ts`](src/adapter.ts) | The `DeepSeekAdapter`: model resolution, image projection, Files fallback, streaming with idle timeout |
|
||||
| [`src/file-store.ts`](src/file-store.ts) + [`src/files-api.ts`](src/files-api.ts) | Scoped upload caching, expiry, stale-id recovery, quota cleanup, and remote file operations |
|
||||
| [`src/serialize.ts`](src/serialize.ts) | Wire serialization: thinking defaults, Files or inline image blocks, history rules |
|
||||
| [`src/sse.ts`](src/sse.ts) | `eventsource-parser` SSE framing for the direct `fetch` stream |
|
||||
| [`src/translate.ts`](src/translate.ts) | SSE payload translation into harness `StreamChunk` values; tool-call `id` and `name` are identity, so a continuation delta repeating them empty or null leaves the established value alone |
|
||||
| [`src/types.ts`](src/types.ts) | Wire-level types shared by the modules above |
|
||||
| [`src/index.ts`](src/index.ts) | Settings, credentials, and provider registration |
|
||||
| [`src/config.ts`](src/config.ts) | Schema and request-local configuration resolution |
|
||||
| [`src/adapter.ts`](src/adapter.ts) | Protocol dispatch with frozen prepared-call configuration |
|
||||
| [`src/common/models.ts`](src/common/models.ts) | Shared model catalog |
|
||||
| [`src/common/model-info.ts`](src/common/model-info.ts) | Shared model capabilities and reasoning choices |
|
||||
| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat transport, Files cache, image projection, and request extensions |
|
||||
| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages transport, serialization, inline images, and native replay |
|
||||
|
||||
### Wire flow
|
||||
|
||||
@@ -178,6 +196,8 @@ Loop-retained response blocks append to the next request and preserve its earlie
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Messages does not use the DeepSeek Files API or Chat-specific request extensions. Responses is not implemented; configuration rejects `responses`.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
|
||||
@@ -189,8 +209,7 @@ These limits define where the adapter stops and future work begins. They are cur
|
||||
- **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`.
|
||||
- **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery.
|
||||
- The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts).
|
||||
|
||||
- The default request-image projection caps total pixels at 640,000, below the provider's roughly 1300×1300 processing budget, so it can discard usable detail. Each model's `imagePixelBudget` can override this default; changing the default affects request content and needs separate snapshot verification ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md)).
|
||||
- The [Messages system-update e2e checks](tests/messages/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "面向用户与维护者的 DeepSeek chat-completions 适配器说明:配置 deepseek-official 路由、thinking 与图片输入。"
|
||||
description: "通过同一 DeepSeek 配置选择 Chat Completions 或 Messages 协议,并配置模型、推理与图片输入。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
使用本包可通过 `deepseek-official` 路由流式调用 DeepSeek 模型,包括配置 thinking 与推理强度、向视觉模型输入图片,以及查看建议性模型目录。端点、凭据、目录与 thinking 策略均按请求解析,因此有效的用户设置更改会在下一个请求生效,无需重启进程。它适合 DeepSeek 官方 API 或 OpenAI 兼容网关;由于路由名不同,可与 pi-ai 包并用。
|
||||
使用本包可通过 `deepseek-official` 调用 DeepSeek 模型,在 Cordis YAML 中选择 Chat Completions 或 Messages 协议。两种协议共用凭据、端点配置和模型目录;有效的设置更改在后续请求生效,进行中的请求保留原配置。Web 始终显示 DeepSeek,并提供 API 地址和密钥编辑。它可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -36,6 +36,7 @@ kind: "package-reference"
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
protocol: chat-completions # chat-completions | messages
|
||||
apiKeyEnv: DEEPSEEK_API_KEY # credential reference, resolved per request
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then this default
|
||||
reasoningEffort: high # optional; off | low | high | max
|
||||
@@ -50,8 +51,9 @@ kind: "package-reference"
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `protocol` | `chat-completions` | Cordis YAML 中选择 `chat-completions` 或 `messages`;Web 不提供选择器 |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 按请求解析的凭据引用:先经凭据 seam,再到环境变量 |
|
||||
| `baseURL` | `https://api.deepseek.com` | 端点基址;设置了 `$DEEPSEEK_BASE_URL` 时优先 |
|
||||
| `baseURL` | 按协议选择官方根地址 | 显式值优先,其次 `$DEEPSEEK_BASE_URL`,最后采用当前协议的官方端点 |
|
||||
| `thinking` | `enabled` | 部署策略;`disabled` 把所有请求锁定为 `off` |
|
||||
| `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` |
|
||||
| `maxTokens` | `256,000` | 单次请求输出上限;模型自身上限与显式请求值优先 |
|
||||
@@ -72,11 +74,27 @@ kind: "package-reference"
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-deepseek)是每个受支持字段及其 JSDoc 的穷尽式真源。
|
||||
|
||||
<a id="choose-a-protocol"></a>
|
||||
### 选择协议
|
||||
|
||||
通过 Cordis 补丁将已有插件切换到 Messages:
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
protocol: messages
|
||||
baseURL: https://api.deepseek.com/anthropic
|
||||
```
|
||||
|
||||
`protocol` 默认为 `chat-completions`,官方根地址为 `https://api.deepseek.com`;`messages` 的官方根地址为 `https://api.deepseek.com/anthropic`。只有未提供 `baseURL` 或环境覆盖时才使用官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。Chat 追加 `/chat/completions`,Messages 追加 `/v1/messages`;除去末尾斜线之外,不推测或删除自定义路径中的 `/v1` 等后缀。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
|
||||
|
||||
Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并使用内联 base64 图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。
|
||||
|
||||
### 带 thinking 与图片的流式调用
|
||||
|
||||
支持图片的路由会在自身像素与字节预算内把每个持久引用解析为确定性请求版本。`imagePixelBudget` 接受正整数或 `low`;省略时使用总计 640,000 像素,`low` 使用总计 512×512 像素,`imageMaxBytes` 默认为 1 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。
|
||||
支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。
|
||||
|
||||
适配器通常通过 DeepSeek Files API 上传这些确切请求字节,并发送 file-id 块。文件解析失败或超时会用相同请求版本的 base64 data URL 重建整份 chat 请求;一次请求绝不混用 file id 与内联图片。缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
|
||||
Chat Completions 通常通过 DeepSeek Files API 上传这些确切请求字节,并发送 file-id 块。文件解析失败或超时会用相同请求版本的 base64 data URL 重建整份 chat 请求;一次请求绝不混用 file id 与内联图片。缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。
|
||||
|
||||
Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留请求版本;内联回退有独立 base64 预算。两种模式都按配置的字节或数量量子移除最旧前缀。每张省略图片都有自己的模型可见占位符,包含显示名或附件 id,以及可用时的规范化尺寸、媒体类型与当前只读路径。分阶高水位策略避免每新增一张图片都改写旧请求前缀。
|
||||
|
||||
@@ -112,13 +130,13 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、按请求解析、settings 与凭据接线 |
|
||||
| [`src/adapter.ts`](src/adapter.ts) | `DeepSeekAdapter`:模型解析、图片投影、Files 回退、带空闲超时的流式调用 |
|
||||
| [`src/file-store.ts`](src/file-store.ts) + [`src/files-api.ts`](src/files-api.ts) | 限定作用域的上传缓存、到期、陈旧 id 恢复、配额清理与远程文件操作 |
|
||||
| [`src/serialize.ts`](src/serialize.ts) | 协议序列化:thinking 默认值、Files 或内联图片块、历史规则 |
|
||||
| [`src/sse.ts`](src/sse.ts) | 直接 `fetch` 流的 `eventsource-parser` SSE 分帧 |
|
||||
| [`src/translate.ts`](src/translate.ts) | 把 SSE 载荷翻译为 harness `StreamChunk` 值;工具调用的 `id` 与 `name` 是身份,后续分片重复发送空串或 null 时保留已建立的值 |
|
||||
| [`src/types.ts`](src/types.ts) | 上述模块共享的协议级类型 |
|
||||
| [`src/index.ts`](src/index.ts) | settings、凭据与提供方注册 |
|
||||
| [`src/config.ts`](src/config.ts) | schema 与请求配置解析 |
|
||||
| [`src/adapter.ts`](src/adapter.ts) | 按协议分派,并冻结已准备请求的配置 |
|
||||
| [`src/common/models.ts`](src/common/models.ts) | 共享模型目录 |
|
||||
| [`src/common/model-info.ts`](src/common/model-info.ts) | 共享模型能力与推理选项 |
|
||||
| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat 传输、Files 缓存、图片投影与请求扩展 |
|
||||
| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages 传输、序列化、内联图片与原生回放 |
|
||||
|
||||
### 协议流程
|
||||
|
||||
@@ -178,6 +196,8 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Messages 不使用 DeepSeek Files API 或 Chat 专属请求扩展;Responses 协议尚未实现,配置值 `responses` 会被拒绝。
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
|
||||
@@ -189,8 +209,7 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复
|
||||
- **跳过插件新增的内容块类型**——核心文本与受支持图片块会被序列化,空工具输出以字面量 `(no output)` 过线。
|
||||
- **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。
|
||||
- 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。
|
||||
|
||||
- 默认请求图片投影限制为 640,000 总像素,低于提供方约 1300×1300 的处理预算,可能丢弃可用细节。每个模型的 `imagePixelBudget` 可以覆盖默认值。更改默认值会改变请求内容,需要单独验证快照([决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md))。
|
||||
- [Messages system 更新 e2e](tests/messages/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-llm-deepseek",
|
||||
"description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam",
|
||||
"description": "DeepSeek adapter with Chat Completions and Messages protocols",
|
||||
"version": "0.1.5-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -64,6 +64,14 @@
|
||||
"@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-http-proxy": "workspace:^"
|
||||
"@deepseek-ai/dsh-http-proxy": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-file": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,715 +1,56 @@
|
||||
/**
|
||||
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
|
||||
* chat-completions endpoint, emitting harness StreamChunks. The adapter is
|
||||
* transport-only: connection facts arrive through a thunk resolved once per
|
||||
* operation and the bearer token through a per-request resolver, so the
|
||||
* registering plugin owns validation, layering, and credential policy.
|
||||
*
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
/** Select a DeepSeek wire implementation from one validated configuration generation. */
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { DeepSeekAdapterOptions } from './common/types.ts'
|
||||
import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts'
|
||||
import { DeepSeekFileStore } from './protocols/chat-completions/file-store.ts'
|
||||
import { DeepSeekMessagesAdapter } from './protocols/messages/adapter.ts'
|
||||
|
||||
import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
GenerateOptions,
|
||||
ImageAttachmentAccess,
|
||||
LlmModelInfo,
|
||||
LlmProviderInfo,
|
||||
PreparedAdapterCall,
|
||||
LlmResolvedModelInfo,
|
||||
ModelModality,
|
||||
ResolvedRetryPolicy,
|
||||
StreamChunk,
|
||||
SystemPromptUpdate,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
AttachmentId,
|
||||
AttachmentStore,
|
||||
ImageAttachmentRef,
|
||||
RequestImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { deadline, idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
|
||||
import type {
|
||||
DeepSeekLlmApiExtensionRequest,
|
||||
DeepSeekLlmApiJson,
|
||||
PreparedDeepSeekLlmApiExtensions,
|
||||
} from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
|
||||
import { serializeRequest, serializeRequestWithImages } from './serialize.ts'
|
||||
import type { ImageWireLocation, RequestDefaults } from './serialize.ts'
|
||||
import { deepSeekImageRequestPricing, resolveRequestImagePolicy } from './request-pricing.ts'
|
||||
import { DeepSeekFileStore } from './file-store.ts'
|
||||
import type { DeepSeekFilePolicy } from './file-store.ts'
|
||||
import type { DeepSeekFileId } from './file-id.ts'
|
||||
import { parseSse } from './sse.ts'
|
||||
import { translate } from './translate.ts'
|
||||
import type { WireError, WireRequest } from './types.ts'
|
||||
|
||||
/** One optional model entry advertised by the direct-fetch adapter. */
|
||||
export interface DeepSeekCatalogModel {
|
||||
/** Wire model id accepted by the configured endpoint. */
|
||||
id: string
|
||||
/** Selector label; defaults to {@link id}. */
|
||||
name?: string
|
||||
/** Optional selector detail for deployments with similar model variants. */
|
||||
description?: string
|
||||
/** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */
|
||||
contextWindow?: number
|
||||
/** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
|
||||
maxTokens?: number
|
||||
/** Accepted request modalities; omission is text-only. */
|
||||
inputModalities?: ModelModality[]
|
||||
/** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
|
||||
imagePixelBudget?: number | 'low'
|
||||
/** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */
|
||||
imageMaxBytes?: number
|
||||
/**
|
||||
* `'in-history'` declares that the endpoint reads the latest `system`
|
||||
* message at any position of the conversation as the complete effective
|
||||
* system prompt; omission means only a leading system message is read.
|
||||
*/
|
||||
systemPromptUpdate?: SystemPromptUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated connection facts for one operation. The plugin's
|
||||
* `resolveAdapterOptions` is the one explicit resolve step producing this
|
||||
* shape; the adapter trusts it and re-reads it per operation, which is what
|
||||
* makes a configuration change reach the next request without re-registration.
|
||||
*/
|
||||
export interface DeepSeekConnectionOptions {
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
/**
|
||||
* Credential reference of this same resolution, resolved per request.
|
||||
* Travelling with the endpoint is the point: a request can never pair one
|
||||
* generation's URL with another generation's secret. Configuration carries
|
||||
* only this name — a literal key is not a configuration value.
|
||||
*/
|
||||
apiKeyEnv: CredentialRef
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults: RequestDefaults
|
||||
/** Default per-request output cap; explicit request values win. */
|
||||
maxTokens: number
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
defaultContextWindow: number
|
||||
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
|
||||
models: readonly DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
streamIdleTimeoutMs: number
|
||||
/** Maximum accumulated file-referenced image bytes in one request. */
|
||||
maxRequestFilesBytes: number
|
||||
/** Maximum accumulated base64 image payload after Files API fallback. */
|
||||
maxInlineRequestImageBytes: number
|
||||
/** Maximum number of represented images in one request. */
|
||||
maxImagesPerRequest: number
|
||||
/** Raw-byte removal step after the file-reference bound is exceeded. */
|
||||
imageOffloadByteQuantum: number
|
||||
/** Base64-byte removal step after the inline fallback bound is exceeded. */
|
||||
inlineImageOffloadByteQuantum: number
|
||||
/** Image-count removal step after the count bound is exceeded. */
|
||||
imageOffloadCountQuantum: number
|
||||
/** Maximum duration of one request-image Files API resolution. */
|
||||
filesApiTimeoutMs: number
|
||||
/** Upload expiry, refresh, and quota-recovery policy. */
|
||||
filePolicy: DeepSeekFilePolicy
|
||||
/** Provider-owned model-request retry policy, already resolved. */
|
||||
retryPolicy: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Constructor options for {@link DeepSeekAdapter}: the operation-local resolution hooks the plugin owns. */
|
||||
export interface DeepSeekAdapterOptions {
|
||||
/** Current validated connection facts; called once per operation. */
|
||||
options: () => DeepSeekConnectionOptions
|
||||
/**
|
||||
* Resolve the bearer token for the connection facts of one request. The
|
||||
* snapshot is passed in — never re-read — so the key can only ever come
|
||||
* from the same resolution as the endpoint it is sent to. Throws `LlmError`
|
||||
* `MISSING_CREDENTIAL` when no key is available anywhere.
|
||||
*/
|
||||
resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
|
||||
/** Resolve the harness-home anonymous id shared with telemetry and feedback. */
|
||||
resolveUserId: () => AnonymousUserId
|
||||
/** Resolve the current durable attachment service; absence rejects image input. */
|
||||
resolveAttachments?: () => AttachmentStore | undefined
|
||||
/** Bridge one attachment reference into the current model-tool execution world. */
|
||||
resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined
|
||||
/** Resolve the process-wide upload reuse store. */
|
||||
resolveFiles?: () => DeepSeekFileStore
|
||||
/** Prepare the official API's plugin-contributed top-level fields for one exact wire request. */
|
||||
prepareExtensions: (request: DeepSeekLlmApiExtensionRequest) => Promise<PreparedDeepSeekLlmApiExtensions>
|
||||
}
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
/** Default combined request/response context capacity. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 1_000_000
|
||||
/** Default per-request output-token cap. */
|
||||
export const DEFAULT_MAX_TOKENS = 256_000
|
||||
/** Default bound on accumulated base64 image payload after Files API fallback. */
|
||||
export const DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
/** Deterministic raw-byte removal step. */
|
||||
export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024
|
||||
/** Deterministic base64-byte removal step after Files API fallback. */
|
||||
export const DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM = 10 * 1024 * 1024
|
||||
/** Deterministic image-count removal step. */
|
||||
export const DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM = 20
|
||||
/** Default explicit lifetime for uploaded images. */
|
||||
export const DEFAULT_FILE_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
/** Default proactive refresh window for indexed file ids. */
|
||||
export const DEFAULT_FILE_REFRESH_MARGIN_SECONDS = 60 * 60
|
||||
/** Default number of oldest harness-owned files removed on quota recovery. */
|
||||
export const DEFAULT_FILE_QUOTA_CLEANUP_BATCH = 100
|
||||
/** Default deadline for resolving one request image through the Files API. */
|
||||
export const DEFAULT_FILES_API_TIMEOUT_MS = 60_000
|
||||
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
|
||||
const FILES_API_TIMEOUT_CODE = 'DEEPSEEK_FILES_API_TIMEOUT'
|
||||
const OFF_REASONING_EFFORT = ReasoningEffortId('off')
|
||||
const LOW_REASONING_EFFORT = ReasoningEffortId('low')
|
||||
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
|
||||
const MAX_REASONING_EFFORT = ReasoningEffortId('max')
|
||||
const REASONING_EFFORTS = [
|
||||
{
|
||||
id: OFF_REASONING_EFFORT,
|
||||
name: 'Off',
|
||||
description: 'Use for simple tasks that do not need reasoning.',
|
||||
},
|
||||
{
|
||||
id: LOW_REASONING_EFFORT,
|
||||
name: 'Low',
|
||||
description: 'Prefer for routine or latency-sensitive tasks.',
|
||||
},
|
||||
{
|
||||
id: HIGH_REASONING_EFFORT,
|
||||
name: 'High',
|
||||
description: 'The default balance for most tasks.',
|
||||
},
|
||||
{
|
||||
id: MAX_REASONING_EFFORT,
|
||||
name: 'Max',
|
||||
description: 'Reserve for the hardest quality-first tasks.',
|
||||
},
|
||||
] as const
|
||||
const OFF_ONLY_REASONING_EFFORTS = [
|
||||
{
|
||||
id: OFF_REASONING_EFFORT,
|
||||
name: 'Off',
|
||||
description: 'Use for simple tasks that do not need reasoning.',
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Marks a failed file-id resolution that may be retried as an inline request. */
|
||||
class FileResolutionFailure extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super('DeepSeek Files API could not resolve a request image.', { cause })
|
||||
this.name = 'FileResolutionFailure'
|
||||
}
|
||||
}
|
||||
|
||||
function collectImageRefs(
|
||||
content: readonly ContentBlock[],
|
||||
refs: Map<AttachmentId, ImageAttachmentRef>,
|
||||
): void {
|
||||
for (const block of content) {
|
||||
if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment)
|
||||
else if (block.type === 'tool-result') collectImageRefs(block.content, refs)
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRequestImages(
|
||||
options: GenerateOptions,
|
||||
attachments: AttachmentStore,
|
||||
model: DeepSeekCatalogModel,
|
||||
signal: AbortSignal,
|
||||
): Promise<Map<AttachmentId, RequestImageAttachment>> {
|
||||
const refs = new Map<AttachmentId, ImageAttachmentRef>()
|
||||
for (const message of options.messages) collectImageRefs(message.content, refs)
|
||||
const policy = resolveRequestImagePolicy(model)
|
||||
const orderedRefs = [...refs.values()]
|
||||
const projected = await Promise.all(orderedRefs.map(
|
||||
ref => attachments.readImageRequest(ref, policy, signal),
|
||||
))
|
||||
return new Map(orderedRefs.map((ref, index) => (
|
||||
[ref.attachmentId, projected[index] as RequestImageAttachment]
|
||||
)))
|
||||
}
|
||||
|
||||
function providerRejectedNormalizedImage(detail: string): boolean {
|
||||
const reasonBeforeImage = /(?:unsupported|invalid|cannot read|failed to (?:decode|process)).{0,40}image/iu
|
||||
const imageBeforeReason = /image.{0,40}(?:unsupported|invalid|cannot be decoded)/iu
|
||||
return reasonBeforeImage.test(detail) || imageBeforeReason.test(detail)
|
||||
}
|
||||
|
||||
interface UsedRequestFile {
|
||||
version: RequestImageAttachment
|
||||
fileId: DeepSeekFileId
|
||||
location: ImageWireLocation
|
||||
}
|
||||
|
||||
function providerRejectedFileId(detail: string): boolean {
|
||||
const file = /\bfile(?:[_ -]?(?:id|api|not[_ -]?found|deleted|expired))?/iu.test(detail)
|
||||
const missing = /(?:expired|not[_ -]?found|deleted|do(?:es)? not exist|not created under (?:this|your) account)/iu.test(detail)
|
||||
const invalidId = /(?:invalid.{0,20}file[_ -]?(?:id|api)|file[_ -]?(?:id|api).{0,20}invalid)/iu.test(detail)
|
||||
return file && (missing || invalidId)
|
||||
}
|
||||
|
||||
function detailNamesFileId(detail: string, fileId: DeepSeekFileId): boolean {
|
||||
let index = detail.indexOf(fileId)
|
||||
while (index >= 0) {
|
||||
const before = detail[index - 1]
|
||||
const after = detail[index + fileId.length]
|
||||
if ((before === undefined || !/[\p{L}\p{N}_-]/u.test(before))
|
||||
&& (after === undefined || !/[\p{L}\p{N}_-]/u.test(after))) return true
|
||||
index = detail.indexOf(fileId, index + 1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function staleMappings(
|
||||
files: readonly UsedRequestFile[],
|
||||
detail: string,
|
||||
): UsedRequestFile[] {
|
||||
const unique = [...new Map(files.map(file => [`${file.version.variantId}\0${file.fileId}`, file])).values()]
|
||||
const exact = unique.filter(file => detailNamesFileId(detail, file.fileId))
|
||||
return exact.length > 0 ? exact : unique
|
||||
}
|
||||
|
||||
function normalizedImageFacts(
|
||||
file: { version: RequestImageAttachment; location: ImageWireLocation },
|
||||
): string {
|
||||
const version = file.version
|
||||
const name = version.attachment.name ?? version.attachment.attachmentId
|
||||
const colour = version.hasAlpha ? 'sRGBA' : 'sRGB'
|
||||
return `"${name}" at message ${file.location.message}, image ${file.location.image} `
|
||||
+ `(${version.mediaType}, 8-bit ${colour}, ${version.width}x${version.height})`
|
||||
}
|
||||
|
||||
function normalizedImageDiagnostic(
|
||||
files: readonly UsedRequestFile[],
|
||||
providerMessage: string,
|
||||
providerDetail: string,
|
||||
): string {
|
||||
const exact = files.find(file => detailNamesFileId(providerDetail, file.fileId))
|
||||
const target = exact ?? (files.length === 1 ? files[0] : undefined)
|
||||
if (target !== undefined) {
|
||||
return `DeepSeek rejected normalized image ${normalizedImageFacts(target)}: ${providerMessage}. `
|
||||
+ 'The provider rejected bytes already normalized by the harness; PNG, JPEG, WebP, and GIF remain supported input formats.'
|
||||
}
|
||||
const candidates = [...new Map(files.map(file => [
|
||||
`${file.version.variantId}\0${file.location.message}\0${file.location.image}`,
|
||||
file,
|
||||
])).values()]
|
||||
return `DeepSeek rejected a normalized request image: ${providerMessage}. Candidate images: `
|
||||
+ `${candidates.map(normalizedImageFacts).join('; ')}. `
|
||||
+ 'The provider rejected bytes already normalized by the harness; PNG, JPEG, WebP, and GIF remain supported input formats.'
|
||||
}
|
||||
|
||||
function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo {
|
||||
return {
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
inputModalities: model.inputModalities ?? ['text'],
|
||||
}
|
||||
}
|
||||
|
||||
function providerRetryAfterMs(value: string | null): number | undefined {
|
||||
if (value === null) return undefined
|
||||
if (/^\d+$/.test(value)) {
|
||||
const delay = Number(value) * 1_000
|
||||
return Number.isFinite(delay) && delay > 0 ? delay : undefined
|
||||
}
|
||||
const delay = Date.parse(value) - Date.now()
|
||||
return Number.isFinite(delay) && delay > 0 ? delay : undefined
|
||||
}
|
||||
|
||||
function requestId(headers: Headers): ReturnType<typeof ProviderRequestId> | undefined {
|
||||
const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id')
|
||||
return value === null || value.length === 0 ? undefined : ProviderRequestId(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an HTTP status to a stable LlmError code.
|
||||
* @param status - status of a non-2xx provider response.
|
||||
* @param error - parsed provider error body, when available.
|
||||
* @returns the normalized harness error code.
|
||||
*/
|
||||
export function httpErrorCode(status: number, error?: WireError['error']): string {
|
||||
if (status === 401 || status === 403) return 'AUTH'
|
||||
if (status === 413) return 'INVALID_REQUEST'
|
||||
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
|
||||
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
|
||||
if (status === 429) return 'RATE_LIMIT'
|
||||
if (status === 400) {
|
||||
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
|
||||
return 'INVALID_REQUEST'
|
||||
}
|
||||
if (status >= 500) return 'SERVER'
|
||||
return `HTTP_${status}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The first real `LlmAdapter`. One instance serves every model name it was
|
||||
* registered under (the harness model name IS the wire model name).
|
||||
*
|
||||
* One stable signal reaches both initial fetch and body reads. Caller aborts
|
||||
* map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
|
||||
*/
|
||||
/** One provider route with protocol-local transport and shared credentials and model configuration. */
|
||||
export class DeepSeekAdapter extends LlmAdapter {
|
||||
private readonly files: DeepSeekFileStore
|
||||
|
||||
constructor(private readonly config: DeepSeekAdapterOptions) {
|
||||
constructor(private readonly dependencies: DeepSeekAdapterOptions) {
|
||||
super()
|
||||
this.files = config.resolveFiles?.() ?? new DeepSeekFileStore()
|
||||
this.files = dependencies.resolveFiles?.() ?? new DeepSeekFileStore()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: 'DeepSeek' }
|
||||
}
|
||||
|
||||
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
||||
return this.config.options().retryPolicy
|
||||
}
|
||||
|
||||
override imageRequestPricing(_provider: string, model: string): ReturnType<LlmAdapter['imageRequestPricing']> {
|
||||
// The same access resolution the serializer uses, so priced handle and
|
||||
// placeholder text matches what the request actually sends.
|
||||
const attachments = this.config.resolveAttachments?.()
|
||||
const resolveAccess = attachments === undefined
|
||||
? undefined
|
||||
: (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => (
|
||||
this.config.resolveImageAccess?.(attachments, ref)
|
||||
)
|
||||
return deepSeekImageRequestPricing(this.config.options(), model, resolveAccess)
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
|
||||
}
|
||||
|
||||
override resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve(this.modelInfoFor(this.config.options(), provider, model))
|
||||
}
|
||||
|
||||
private modelInfoFor(
|
||||
connection: DeepSeekConnectionOptions,
|
||||
provider: string,
|
||||
model: string,
|
||||
): LlmResolvedModelInfo {
|
||||
const configured = connection.models.find(entry => entry.id === model)
|
||||
const contextWindow = configured?.contextWindow
|
||||
?? connection.defaultContextWindow
|
||||
return {
|
||||
// An uncatalogued endpoint is safely treated as text-only. Declaring an
|
||||
// unverified image capability would let the host persist input that the
|
||||
// endpoint may reject on every later turn.
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model, inputModalities: ['text' as const] }
|
||||
: modelInfo(provider, configured),
|
||||
context: { contextWindow },
|
||||
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
|
||||
...configured?.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: configured.systemPromptUpdate },
|
||||
...connection.defaults.thinking === 'disabled'
|
||||
? {
|
||||
reasoning: {
|
||||
efforts: OFF_ONLY_REASONING_EFFORTS,
|
||||
defaultEffort: OFF_REASONING_EFFORT,
|
||||
private implementation(): LlmAdapter {
|
||||
const connection = this.dependencies.options()
|
||||
switch (connection.protocol) {
|
||||
case 'messages':
|
||||
return new DeepSeekMessagesAdapter({
|
||||
connection: () => connection,
|
||||
apiKey: this.dependencies.resolveApiKey,
|
||||
userId: this.dependencies.resolveUserId,
|
||||
attachments: () => this.dependencies.resolveAttachments?.(),
|
||||
imageAccess: (ref) => {
|
||||
const attachments = this.dependencies.resolveAttachments?.()
|
||||
return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref)
|
||||
},
|
||||
}
|
||||
: {
|
||||
reasoning: {
|
||||
efforts: REASONING_EFFORTS,
|
||||
defaultEffort: connection.defaults.reasoningEffort === 'off'
|
||||
? OFF_REASONING_EFFORT
|
||||
: connection.defaults.reasoningEffort === 'low'
|
||||
? LOW_REASONING_EFFORT
|
||||
: connection.defaults.reasoningEffort === 'max'
|
||||
? MAX_REASONING_EFFORT
|
||||
: HIGH_REASONING_EFFORT,
|
||||
},
|
||||
},
|
||||
...this.dependencies.onReplayDegrade === undefined ? {} : { onReplayDegrade: this.dependencies.onReplayDegrade },
|
||||
})
|
||||
case 'chat-completions':
|
||||
return new ChatCompletionsAdapter({ ...this.dependencies, options: () => connection, resolveFiles: () => this.files })
|
||||
/* v8 ignore next -- protocol is validated at configuration resolution. */
|
||||
default: return assertNever(connection.protocol, 'DeepSeek protocol')
|
||||
}
|
||||
}
|
||||
|
||||
override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise<PreparedAdapterCall> {
|
||||
const connection = this.config.options()
|
||||
return Promise.resolve({
|
||||
model: this.modelInfoFor(connection, provider, model),
|
||||
stream: options => this.streamWithConnection(options, connection),
|
||||
})
|
||||
override providerInfo(provider: string) { return this.implementation().providerInfo(provider) }
|
||||
override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) }
|
||||
override listModels(provider: string) { return this.implementation().listModels(provider) }
|
||||
override resolveModel(provider: string, model: string, signal?: AbortSignal) {
|
||||
return this.implementation().resolveModel(provider, model, signal)
|
||||
}
|
||||
override imageRequestPricing(provider: string, model: string) {
|
||||
return this.implementation().imageRequestPricing(provider, model)
|
||||
}
|
||||
override prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall> {
|
||||
return this.implementation().prepareCall(provider, model, signal)
|
||||
}
|
||||
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.streamWithConnection(options, this.config.options())
|
||||
}
|
||||
|
||||
private async * streamWithConnection(
|
||||
options: GenerateOptions,
|
||||
connection: DeepSeekConnectionOptions,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
// One resolution per stream call: connection facts and the credential
|
||||
// freeze here and hold for this whole request, so an in-flight stream
|
||||
// never observes a configuration change and the next call re-resolves.
|
||||
// The key resolves *from this snapshot*, so an endpoint and the secret
|
||||
// sent to it can never come from different configuration generations.
|
||||
const hasImages = options.messages.some(message => contentHasImage(message.content))
|
||||
let attachments: AttachmentStore | undefined
|
||||
if (hasImages) {
|
||||
const model = connection.models.find(entry => entry.id === options.model)
|
||||
if (model?.inputModalities?.includes('image') !== true) {
|
||||
throw new LlmError(
|
||||
`DeepSeek model "${options.model}" does not accept image input.`,
|
||||
'UNSUPPORTED_CONTENT',
|
||||
)
|
||||
}
|
||||
attachments = this.config.resolveAttachments?.()
|
||||
if (attachments === undefined) {
|
||||
throw new LlmError(
|
||||
'DeepSeek image conversion requires the durable attachment service.',
|
||||
'UNSUPPORTED_CONTENT',
|
||||
)
|
||||
}
|
||||
}
|
||||
const apiKey = await this.config.resolveApiKey(connection)
|
||||
const userId = this.config.resolveUserId()
|
||||
const consumer = new AbortController()
|
||||
const upstream = options.signal === undefined
|
||||
? consumer.signal
|
||||
: AbortSignal.any([options.signal, consumer.signal])
|
||||
using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
|
||||
const iterator = this.request(
|
||||
options,
|
||||
watchdog.signal,
|
||||
connection,
|
||||
apiKey,
|
||||
userId,
|
||||
attachments,
|
||||
() => { watchdog.pulse() },
|
||||
)[Symbol.asyncIterator]()
|
||||
let exhausted = false
|
||||
try {
|
||||
while (true) {
|
||||
const result = await watchdog.next(iterator)
|
||||
if (result.done) {
|
||||
exhausted = true
|
||||
return
|
||||
}
|
||||
yield result.value
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
|
||||
throw new LlmError(
|
||||
`DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`,
|
||||
'TIMEOUT',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error })
|
||||
}
|
||||
if (error instanceof LlmError) throw error
|
||||
throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error })
|
||||
} finally {
|
||||
consumer.abort('DeepSeek stream consumer stopped')
|
||||
if (!exhausted && iterator.return !== undefined) {
|
||||
try {
|
||||
await iterator.return()
|
||||
} catch (_abortedTransportTeardown) {
|
||||
// The consumer controller already owns termination; a return-time abort cannot add a second outcome.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async * request(
|
||||
options: GenerateOptions,
|
||||
signal: AbortSignal,
|
||||
connection: DeepSeekConnectionOptions,
|
||||
apiKey: string,
|
||||
userId: AnonymousUserId,
|
||||
attachments: AttachmentStore | undefined,
|
||||
onActivity: () => void,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const headers = {
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
...attributionHeaders(),
|
||||
'x-deepseek-harness-user-id': String(userId),
|
||||
...options.sessionId !== undefined
|
||||
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
|
||||
: {},
|
||||
...options.purpose === 'compaction'
|
||||
? { 'x-deepseek-harness-compact': '1' }
|
||||
: {},
|
||||
}
|
||||
|
||||
const fileConnection = { baseURL: connection.baseURL, apiKey }
|
||||
const model = connection.models.find(entry => entry.id === options.model)
|
||||
const policy = model === undefined ? undefined : resolveRequestImagePolicy(model)
|
||||
const resolveImageAccess = attachments === undefined
|
||||
? undefined
|
||||
: (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref)
|
||||
const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess }
|
||||
const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, {
|
||||
representation: 'raw',
|
||||
maxBytes: connection.maxRequestFilesBytes,
|
||||
maxImages: connection.maxImagesPerRequest,
|
||||
byteQuantum: connection.imageOffloadByteQuantum,
|
||||
countQuantum: connection.imageOffloadCountQuantum,
|
||||
byteLength: ref => Math.min(ref.bytes, policy.maxBytes),
|
||||
placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)),
|
||||
})
|
||||
const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] }
|
||||
const requestImages = attachments === undefined || model === undefined
|
||||
? new Map<AttachmentId, RequestImageAttachment>()
|
||||
: await prepareRequestImages(requestOptions, attachments, model, signal)
|
||||
let representation: 'file' | 'base64' = 'file'
|
||||
let fileAttempt = 0
|
||||
while (true) {
|
||||
const usedFiles: UsedRequestFile[] = []
|
||||
let body: WireRequest
|
||||
if (attachments === undefined) {
|
||||
body = serializeRequest(requestOptions, connection.defaults)
|
||||
} else if (representation === 'base64') {
|
||||
body = await serializeRequestWithImages(requestOptions, {
|
||||
representation: { kind: 'base64' },
|
||||
requestImages,
|
||||
...imageAccessOptions,
|
||||
maxRequestImageBytes: connection.maxInlineRequestImageBytes,
|
||||
maxImagesPerRequest: connection.maxImagesPerRequest,
|
||||
byteQuantum: connection.inlineImageOffloadByteQuantum,
|
||||
countQuantum: connection.imageOffloadCountQuantum,
|
||||
}, connection.defaults)
|
||||
} else {
|
||||
try {
|
||||
body = await serializeRequestWithImages(requestOptions, {
|
||||
representation: {
|
||||
kind: 'file',
|
||||
resolveFileId: async (version, _block, location) => {
|
||||
using filesDeadline = deadline(signal, connection.filesApiTimeoutMs, FILES_API_TIMEOUT_CODE)
|
||||
let resolved: Awaited<ReturnType<DeepSeekFileStore['ensureUploaded']>>
|
||||
try {
|
||||
resolved = await this.files.ensureUploaded(
|
||||
version,
|
||||
fileConnection,
|
||||
connection.filePolicy,
|
||||
filesDeadline.signal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw error
|
||||
throw new FileResolutionFailure(error)
|
||||
}
|
||||
onActivity()
|
||||
usedFiles.push({ version, fileId: resolved.record.fileId, location })
|
||||
return resolved.record.fileId
|
||||
},
|
||||
},
|
||||
requestImages,
|
||||
...imageAccessOptions,
|
||||
maxRequestImageBytes: connection.maxRequestFilesBytes,
|
||||
maxImagesPerRequest: connection.maxImagesPerRequest,
|
||||
byteQuantum: connection.imageOffloadByteQuantum,
|
||||
countQuantum: connection.imageOffloadCountQuantum,
|
||||
}, connection.defaults)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof FileResolutionFailure)) throw error
|
||||
representation = 'base64'
|
||||
continue
|
||||
}
|
||||
}
|
||||
let extensions: PreparedDeepSeekLlmApiExtensions
|
||||
try {
|
||||
extensions = await this.config.prepareExtensions({
|
||||
body: body as unknown as Readonly<Record<string, DeepSeekLlmApiJson>>,
|
||||
signal,
|
||||
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
|
||||
...options.purpose === undefined ? {} : { purpose: options.purpose },
|
||||
})
|
||||
} catch (error) {
|
||||
throw new LlmError('DeepSeek request extension preparation failed', 'REQUEST_EXTENSION', { cause: error })
|
||||
}
|
||||
for (const field of Object.keys(extensions.fields)) {
|
||||
if (Object.hasOwn(body, field)) {
|
||||
throw new LlmError(`DeepSeek request extension field ${JSON.stringify(field)} collides with the base request`, 'REQUEST_EXTENSION')
|
||||
}
|
||||
}
|
||||
// Prepared outside the try so the TRANSPORT label below covers exactly the
|
||||
// transport boundary, never a serialization failure.
|
||||
const payload = JSON.stringify({ ...body, ...extensions.fields })
|
||||
|
||||
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
|
||||
// outweighs its additional runtime dependencies.
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${connection.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload,
|
||||
signal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw error
|
||||
throw new LlmError(
|
||||
`DeepSeek API request to ${connection.baseURL} failed`,
|
||||
'TRANSPORT',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `DeepSeek API error (HTTP ${response.status})`
|
||||
let providerError: WireError['error']
|
||||
const rawResponse = await response.text()
|
||||
try {
|
||||
const parsed = JSON.parse(rawResponse) as WireError
|
||||
providerError = parsed.error
|
||||
if (providerError?.message) message = providerError.message
|
||||
} catch {
|
||||
// The HTTP status remains authoritative when a gateway returns malformed JSON.
|
||||
}
|
||||
const detail = [providerError?.code, providerError?.type, providerError?.message]
|
||||
.filter((field): field is string => typeof field === 'string')
|
||||
.join(' ')
|
||||
const staleFile = usedFiles.length > 0 && providerRejectedFileId(detail)
|
||||
if (staleFile) {
|
||||
await Promise.all(staleMappings(usedFiles, detail).map(file => (
|
||||
this.files.invalidate(file.version, file.fileId, fileConnection)
|
||||
)))
|
||||
if (fileAttempt === 0) {
|
||||
fileAttempt += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (response.status === 400 && usedFiles.length > 0 && providerRejectedNormalizedImage(detail)) {
|
||||
message = normalizedImageDiagnostic(usedFiles, message, detail)
|
||||
}
|
||||
const delay = providerRetryAfterMs(response.headers.get('retry-after'))
|
||||
const id = requestId(response.headers)
|
||||
throw new LlmError(message, httpErrorCode(response.status, providerError), {
|
||||
cause: new Error(rawResponse.length > 0 ? rawResponse : `DeepSeek HTTP ${response.status}`),
|
||||
status: response.status,
|
||||
...delay === undefined ? {} : { providerRetryAfterMs: delay },
|
||||
...id === undefined ? {} : { requestId: id },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await extensions.accept()
|
||||
} catch (error) {
|
||||
throw new LlmError('DeepSeek request extension acceptance failed', 'REQUEST_EXTENSION', { cause: error })
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
|
||||
}
|
||||
|
||||
yield* translate(parseSse(response.body, onActivity))
|
||||
return
|
||||
}
|
||||
return this.implementation().stream(options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Shared provider limits and Chat Files API defaults. */
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
/** Default combined request/response context capacity. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 1_000_000
|
||||
/** Default per-request output-token cap. */
|
||||
export const DEFAULT_MAX_TOKENS = 256_000
|
||||
/** Default bound on accumulated base64 image payload after Files API fallback. */
|
||||
export const DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
/** Deterministic raw-byte removal step. */
|
||||
export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024
|
||||
/** Deterministic base64-byte removal step after Files API fallback. */
|
||||
export const DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM = 10 * 1024 * 1024
|
||||
/** Deterministic image-count removal step. */
|
||||
export const DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM = 20
|
||||
/** Default explicit lifetime for uploaded images. */
|
||||
export const DEFAULT_FILE_EXPIRY_SECONDS = 7 * 24 * 60 * 60
|
||||
/** Default proactive refresh window for indexed file ids. */
|
||||
export const DEFAULT_FILE_REFRESH_MARGIN_SECONDS = 60 * 60
|
||||
/** Default number of oldest harness-owned files removed on quota recovery. */
|
||||
export const DEFAULT_FILE_QUOTA_CLEANUP_BATCH = 100
|
||||
/** Default deadline for resolving one request image through the Files API. */
|
||||
export const DEFAULT_FILES_API_TIMEOUT_MS = 60_000
|
||||
+21
@@ -11,6 +11,8 @@
|
||||
* @module dsh-llm-deepseek/image-tokens
|
||||
*/
|
||||
|
||||
import { longEdgeDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ProjectedDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
/** Vision patch edge in pixels. */
|
||||
const PATCH_SIZE = 14
|
||||
/** Per-axis patch-to-token downsampling ratio. */
|
||||
@@ -116,6 +118,25 @@ function sameResize(a: GridResize, b: GridResize): boolean {
|
||||
&& a.numTokens === b.numTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions the harness sends so the provider keeps the whole image: the
|
||||
* source itself when its patch-padded grid fits the token cap, otherwise the
|
||||
* source aspect ratio at the solved grid's long edge. The provider pads the
|
||||
* short edge to whole patches on its side. Rounding the aspect-preserving
|
||||
* short edge can change the token count from the source's solved grid;
|
||||
* request pricing uses the sent dimensions. Small images are never enlarged.
|
||||
* @param width - positive integer source width in pixels.
|
||||
* @param height - positive integer source height in pixels.
|
||||
* @returns the request dimensions to encode.
|
||||
*/
|
||||
export function deepSeekRequestImageDimensions(width: number, height: number): ProjectedDimensions {
|
||||
const paddedWidth = ceilDiv(width, PATCH_SIZE) * PATCH_SIZE
|
||||
const paddedHeight = ceilDiv(height, PATCH_SIZE) * PATCH_SIZE
|
||||
if (gridTokens(gridCells(paddedHeight), gridCells(paddedWidth)) <= MAX_IMAGE_TOKENS) return { width, height }
|
||||
const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS)
|
||||
return longEdgeDimensions(width, height, width >= height ? solved.bestWidth : solved.bestHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vision tokens DeepSeek charges for one request image of the given
|
||||
* dimensions.
|
||||
@@ -0,0 +1,99 @@
|
||||
/** Protocol-independent model capabilities and reasoning choices. */
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmModelInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts'
|
||||
|
||||
const OFF_REASONING_EFFORT = ReasoningEffortId('off')
|
||||
const LOW_REASONING_EFFORT = ReasoningEffortId('low')
|
||||
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
|
||||
const MAX_REASONING_EFFORT = ReasoningEffortId('max')
|
||||
const REASONING_EFFORTS = [
|
||||
{
|
||||
id: OFF_REASONING_EFFORT,
|
||||
name: 'Off',
|
||||
description: 'Use for simple tasks that do not need reasoning.',
|
||||
},
|
||||
{
|
||||
id: LOW_REASONING_EFFORT,
|
||||
name: 'Low',
|
||||
description: 'Prefer for routine or latency-sensitive tasks.',
|
||||
},
|
||||
{
|
||||
id: HIGH_REASONING_EFFORT,
|
||||
name: 'High',
|
||||
description: 'The default balance for most tasks.',
|
||||
},
|
||||
{
|
||||
id: MAX_REASONING_EFFORT,
|
||||
name: 'Max',
|
||||
description: 'Reserve for the hardest quality-first tasks.',
|
||||
},
|
||||
] as const
|
||||
const OFF_ONLY_REASONING_EFFORTS = [
|
||||
{
|
||||
id: OFF_REASONING_EFFORT,
|
||||
name: 'Off',
|
||||
description: 'Use for simple tasks that do not need reasoning.',
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Advertise one catalog entry.
|
||||
* @param provider - registered provider id.
|
||||
* @param model - advisory catalog entry.
|
||||
* @returns selector metadata.
|
||||
*/
|
||||
export function catalogModelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo {
|
||||
return {
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
inputModalities: model.inputModalities ?? ['text'],
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve model capabilities against one configuration generation.
|
||||
* @param connection - validated connection facts.
|
||||
* @param provider - registered provider id.
|
||||
* @param model - requested wire model id.
|
||||
* @returns effective model metadata for this operation.
|
||||
*/
|
||||
export function modelInfo(
|
||||
connection: DeepSeekConnectionOptions,
|
||||
provider: string,
|
||||
model: string,
|
||||
): LlmResolvedModelInfo {
|
||||
const configured = connection.models.find(entry => entry.id === model)
|
||||
const contextWindow = configured?.contextWindow
|
||||
?? connection.defaultContextWindow
|
||||
return {
|
||||
// An uncatalogued endpoint is safely treated as text-only. Declaring an
|
||||
// unverified image capability would let the host persist input that the
|
||||
// endpoint may reject on every later turn.
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model, inputModalities: ['text' as const] }
|
||||
: catalogModelInfo(provider, configured),
|
||||
context: { contextWindow },
|
||||
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
|
||||
...configured?.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: configured.systemPromptUpdate },
|
||||
...connection.defaults.thinking === 'disabled'
|
||||
? {
|
||||
reasoning: {
|
||||
efforts: OFF_ONLY_REASONING_EFFORTS,
|
||||
defaultEffort: OFF_REASONING_EFFORT,
|
||||
},
|
||||
}
|
||||
: {
|
||||
reasoning: {
|
||||
efforts: REASONING_EFFORTS,
|
||||
defaultEffort: connection.defaults.reasoningEffort === 'off'
|
||||
? OFF_REASONING_EFFORT
|
||||
: connection.defaults.reasoningEffort === 'low'
|
||||
? LOW_REASONING_EFFORT
|
||||
: connection.defaults.reasoningEffort === 'max'
|
||||
? MAX_REASONING_EFFORT
|
||||
: HIGH_REASONING_EFFORT,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Default catalog shared by every DeepSeek protocol. */
|
||||
import { DEFAULT_CONTEXT_WINDOW } from './defaults.ts'
|
||||
import type { DeepSeekCatalogModel } from './types.ts'
|
||||
|
||||
/** Advisory official model entries; deployments may replace the catalog. */
|
||||
export const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
{
|
||||
id: 'deepseek-flash',
|
||||
name: 'DeepSeek-V41-Flash',
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
inputModalities: ['text', 'image'],
|
||||
systemPromptUpdate: 'in-history',
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
name: 'DeepSeek-V4-Pro',
|
||||
description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-flash-vision-exp',
|
||||
name: 'DeepSeek-V4-Flash-Vision-Exp',
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
inputModalities: ['text', 'image'],
|
||||
},
|
||||
]
|
||||
+44
-25
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Provider-side request-image pricing for DeepSeek routes: reproduces the
|
||||
* adapter's deterministic request projection (per-model pixel budget,
|
||||
* adapter's deterministic request projection (per-model request target,
|
||||
* oldest-first offload under the raw-byte and count budgets) and prices every
|
||||
* retained image with the published vision-token accounting. Consumed
|
||||
* synchronously by the token meter through `LlmAdapter.imageRequestPricing`;
|
||||
@@ -11,38 +11,57 @@
|
||||
|
||||
import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm'
|
||||
import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm'
|
||||
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, ImageRequestPolicy } from '@deepseek-ai/dsh-attachment'
|
||||
import { deepSeekImageTokens } from './image-tokens.ts'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
import { longEdgeDimensions, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, ImageRequestTarget } from '@deepseek-ai/dsh-attachment'
|
||||
import { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts'
|
||||
|
||||
/** Default bound on accumulated file-referenced image bytes per request. */
|
||||
export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024
|
||||
/** Provider request image-count limit. */
|
||||
export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600
|
||||
/** Default total-pixel budget for harness request-image projection. */
|
||||
export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000
|
||||
/** Total-pixel budget matching provider low-detail image input. */
|
||||
export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512
|
||||
/** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */
|
||||
export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
|
||||
export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024
|
||||
/**
|
||||
* Provider per-side limit for a request carrying 15 or more images, applied
|
||||
* to every request image so the image count never changes a projection.
|
||||
*/
|
||||
export const REQUEST_IMAGE_MAX_DIMENSION = 4096
|
||||
|
||||
/**
|
||||
* Resolve the request-image budgets owned by one DeepSeek model route.
|
||||
* Resolve the encoded-byte target one DeepSeek model route applies to every request image.
|
||||
* @param model - Advertised model route and its optional image overrides.
|
||||
* @returns Complete pixel and encoded-byte budgets.
|
||||
* @returns the route's encoded-byte target.
|
||||
* @internal
|
||||
*/
|
||||
export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
|
||||
const maxPixels = model.imagePixelBudget === 'low'
|
||||
? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
|
||||
: model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
|
||||
return {
|
||||
maxPixels,
|
||||
maxBytes: model.imageMaxBytes === undefined
|
||||
? DEFAULT_REQUEST_IMAGE_MAX_BYTES
|
||||
: model.imageMaxBytes,
|
||||
}
|
||||
export function resolveRequestImageMaxBytes(model: DeepSeekCatalogModel): number {
|
||||
return model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deterministic request target one DeepSeek model route chooses
|
||||
* for one source image: the published token grid unless the model overrides
|
||||
* it with a pixel budget, then the provider per-side limit, then the route's
|
||||
* encoded-byte target. Small images are never enlarged.
|
||||
* @param model - Advertised model route and its optional image overrides.
|
||||
* @param source - intrinsic dimensions of the normalized attachment.
|
||||
* @returns Complete request dimensions and encoded-byte target.
|
||||
* @internal
|
||||
*/
|
||||
export function resolveRequestImageTarget(
|
||||
model: DeepSeekCatalogModel,
|
||||
source: Pick<ImageAttachmentRef, 'width' | 'height'>,
|
||||
): ImageRequestTarget {
|
||||
const budget = model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget
|
||||
const projected = budget === undefined
|
||||
? deepSeekRequestImageDimensions(source.width, source.height)
|
||||
: requestImageDimensions(source.width, source.height, budget)
|
||||
const capped = Math.max(projected.width, projected.height) > REQUEST_IMAGE_MAX_DIMENSION
|
||||
? longEdgeDimensions(source.width, source.height, REQUEST_IMAGE_MAX_DIMENSION)
|
||||
: projected
|
||||
return { ...capped, maxBytes: resolveRequestImageMaxBytes(model) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,11 +98,11 @@ export function deepSeekImageRequestPricing(
|
||||
if (catalogModel?.inputModalities?.includes('image') !== true) {
|
||||
return { priceImages: images => images.map(textOnlyPrice) }
|
||||
}
|
||||
const policy = resolveRequestImagePolicy(catalogModel)
|
||||
const maxBytes = resolveRequestImageMaxBytes(catalogModel)
|
||||
return {
|
||||
priceImages: (images) => {
|
||||
const offloaded = offloadedImagePrefixCount(
|
||||
images.map(ref => Math.min(ref.bytes, policy.maxBytes)),
|
||||
images.map(ref => Math.min(ref.bytes, maxBytes)),
|
||||
{
|
||||
maxBytes: connection.maxRequestFilesBytes,
|
||||
maxImages: connection.maxImagesPerRequest,
|
||||
@@ -95,10 +114,10 @@ export function deepSeekImageRequestPricing(
|
||||
if (index < offloaded) {
|
||||
return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) }
|
||||
}
|
||||
const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels)
|
||||
const target = resolveRequestImageTarget(catalogModel, ref)
|
||||
return {
|
||||
visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height),
|
||||
text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)),
|
||||
visualTokens: deepSeekImageTokens(target.width, target.height),
|
||||
text: requestImageHandleText(ref, target, resolveAccess?.(ref)),
|
||||
}
|
||||
})
|
||||
},
|
||||
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user