fix: harden Web image admission
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. 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: 38331df827d64c8370d0208f6898971e46010afa
|
||||
README.zh.md: bc6a471465c7bffc136e8ec2233e200a292f2d1a
|
||||
README.md: 310120bd1c3573da1e7c60334d5c2712195f3186
|
||||
README.zh.md: 9fd3a857eca1c25c90b665735f67d2c27c92334a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash; reads re-check the digest, media signature, dimensions, and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
|
||||
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在;读取过程会重新校验摘要、媒体签名、尺寸和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": { "schemastery": "^3.18.0" },
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -1,102 +1,45 @@
|
||||
/** Minimal raster header validation used before bytes enter durable storage. */
|
||||
/** Raster decoding used before bytes enter durable storage. */
|
||||
|
||||
import sharp from 'sharp'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
|
||||
/** Decoded metadata from a supported image header. */
|
||||
/** Decoded metadata from a supported image. */
|
||||
export interface DetectedImage {
|
||||
mediaType: ImageMediaType
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function ascii(data: Uint8Array, start: number, value: string): boolean {
|
||||
/* v8 ignore next -- Every call site establishes the fixed header span before comparing it. */
|
||||
if (data.length < start + value.length) return false
|
||||
for (let i = 0; i < value.length; i++) if (data[start + i] !== value.charCodeAt(i)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function u16be(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset)
|
||||
}
|
||||
|
||||
function u16le(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true)
|
||||
}
|
||||
|
||||
function u24le(data: Uint8Array, offset: number): number {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
return view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getUint8(offset + 2) << 16)
|
||||
}
|
||||
|
||||
function u32be(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset)
|
||||
}
|
||||
|
||||
function u32le(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true)
|
||||
}
|
||||
|
||||
function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage {
|
||||
if (width < 1 || height < 1) throw new AttachmentError('Image dimensions must be positive.', 'INVALID_IMAGE')
|
||||
return { mediaType, width, height }
|
||||
}
|
||||
|
||||
function jpeg(data: Uint8Array): DetectedImage | null {
|
||||
if (data[0] !== 0xff || data[1] !== 0xd8) return null
|
||||
const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf])
|
||||
let offset = 2
|
||||
while (offset + 3 < data.length) {
|
||||
while (data[offset] === 0xff) offset++
|
||||
const marker = data[offset]
|
||||
if (marker === undefined || marker === 0xd9 || marker === 0xda) break
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
offset++
|
||||
continue
|
||||
}
|
||||
const length = u16be(data, offset + 1)
|
||||
if (length < 2 || offset + 1 + length > data.length) throw new AttachmentError('JPEG data is truncated.', 'INVALID_IMAGE')
|
||||
if (sof.has(marker)) {
|
||||
if (length < 7) throw new AttachmentError('JPEG dimensions are truncated.', 'INVALID_IMAGE')
|
||||
return dimensions(u16be(data, offset + 6), u16be(data, offset + 4), 'image/jpeg')
|
||||
}
|
||||
offset += length + 1
|
||||
}
|
||||
throw new AttachmentError('JPEG dimensions are missing.', 'INVALID_IMAGE')
|
||||
const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a supported raster type and intrinsic dimensions from encoded bytes.
|
||||
* Decode a supported raster and return its intrinsic metadata.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @param maxPixels - optional write-time decoded-pixel limit; reads omit it.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export function detectImage(data: Uint8Array): DetectedImage {
|
||||
if (data.length >= 24
|
||||
&& data[0] === 0x89 && ascii(data, 1, 'PNG\r\n\u001a\n') && ascii(data, 12, 'IHDR')) {
|
||||
return dimensions(u32be(data, 16), u32be(data, 20), 'image/png')
|
||||
}
|
||||
if (data.length >= 10 && (ascii(data, 0, 'GIF87a') || ascii(data, 0, 'GIF89a'))) {
|
||||
return dimensions(u16le(data, 6), u16le(data, 8), 'image/gif')
|
||||
}
|
||||
const detectedJpeg = jpeg(data)
|
||||
if (detectedJpeg !== null) return detectedJpeg
|
||||
if (data.length >= 30 && ascii(data, 0, 'RIFF') && ascii(data, 8, 'WEBP')) {
|
||||
const declaredLength = u32le(data, 4) + 8
|
||||
if (declaredLength > data.length) throw new AttachmentError('WebP data is truncated.', 'INVALID_IMAGE')
|
||||
if (ascii(data, 12, 'VP8X')) return dimensions(u24le(data, 24) + 1, u24le(data, 27) + 1, 'image/webp')
|
||||
if (ascii(data, 12, 'VP8L') && data[20] === 0x2f) {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
const b0 = view.getUint8(21)
|
||||
const b1 = view.getUint8(22)
|
||||
const b2 = view.getUint8(23)
|
||||
const b3 = view.getUint8(24)
|
||||
return dimensions(1 + b0 + ((b1 & 0x3f) << 8), 1 + (b1 >> 6) + (b2 << 2) + ((b3 & 0x0f) << 10), 'image/webp')
|
||||
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
|
||||
try {
|
||||
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
|
||||
const metadata = await image.metadata()
|
||||
const mediaType = MEDIA_TYPES[metadata.format as string]
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) {
|
||||
return dimensions(u16le(data, 26) & 0x3fff, u16le(data, 28) & 0x3fff, 'image/webp')
|
||||
const { width, height } = metadata
|
||||
if (maxPixels !== undefined && width * height > maxPixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
throw new AttachmentError('WebP dimensions are missing.', 'INVALID_IMAGE')
|
||||
await image.raw().toBuffer()
|
||||
return { mediaType, width, height }
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
}
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
})
|
||||
}
|
||||
|
||||
validateImage(input: SaveImageAttachment): void {
|
||||
validateImageFile(input, this.imageLimits)
|
||||
async validateImage(input: SaveImageAttachment): Promise<void> {
|
||||
await validateImageFile(input, this.imageLimits)
|
||||
}
|
||||
|
||||
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
|
||||
@@ -38,27 +38,28 @@ function ensureReference(ref: ImageAttachmentRef): string {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit<ImageAttachmentRef, 'attachmentId' | 'name'> {
|
||||
async function inspectMetadata(
|
||||
data: Uint8Array,
|
||||
declaredMediaType: ImageAttachmentRef['mediaType'],
|
||||
maxPixels?: number,
|
||||
): Promise<Omit<ImageAttachmentRef, 'attachmentId' | 'name'>> {
|
||||
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
|
||||
const detected = detectImage(data)
|
||||
const detected = await detectImage(data, maxPixels)
|
||||
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
|
||||
return { ...detected, bytes: data.byteLength }
|
||||
}
|
||||
|
||||
function validateAdmission(metadata: Omit<ImageAttachmentRef, 'attachmentId' | 'name'>, limits: ImageAttachmentLimits): void {
|
||||
if (metadata.bytes > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
if (metadata.width * metadata.height > limits.maxImagePixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full admission policy for one image without touching storage.
|
||||
* @param input - encoded bytes and declared metadata.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns completion after the encoded raster has been fully decoded.
|
||||
*/
|
||||
export function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): void {
|
||||
validateAdmission(inspectMetadata(input.data, input.mediaType), limits)
|
||||
export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<void> {
|
||||
if (input.data.byteLength > limits.maxImageBytes) {
|
||||
throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
}
|
||||
await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +113,8 @@ async function ensureDurableDirectory(path: string, boundary: string): Promise<v
|
||||
* @returns durable content-addressed reference.
|
||||
*/
|
||||
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
|
||||
const metadata = inspectMetadata(input.data, input.mediaType)
|
||||
validateAdmission(metadata, limits)
|
||||
if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
@@ -187,7 +188,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
const metadata = inspectMetadata(data, ref.mediaType)
|
||||
const metadata = await inspectMetadata(data, ref.mediaType)
|
||||
if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
|
||||
@@ -1,93 +1,42 @@
|
||||
import sharp from 'sharp'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectImage } from '../src/image.ts'
|
||||
|
||||
function bytes(text: string): number[] {
|
||||
return [...Buffer.from(text, 'ascii')]
|
||||
async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
|
||||
const image = sharp({
|
||||
create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } },
|
||||
})
|
||||
return new Uint8Array(await image.toFormat(format).toBuffer())
|
||||
}
|
||||
|
||||
function webp(chunk: string, mutate: (data: Uint8Array) => void): Uint8Array {
|
||||
const data = new Uint8Array(30)
|
||||
data.set(bytes('RIFF'), 0)
|
||||
data.set([22, 0, 0, 0], 4)
|
||||
data.set(bytes('WEBP'), 8)
|
||||
data.set(bytes(chunk), 12)
|
||||
mutate(data)
|
||||
return data
|
||||
}
|
||||
|
||||
describe('raster header detection', () => {
|
||||
it('detects PNG dimensions', () => {
|
||||
const data = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
expect(detectImage(data)).toEqual({ mediaType: 'image/png', width: 1, height: 1 })
|
||||
describe('raster decoding', () => {
|
||||
it('decodes every supported format and its intrinsic dimensions', async () => {
|
||||
for (const [format, mediaType] of [
|
||||
['png', 'image/png'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['webp', 'image/webp'],
|
||||
['gif', 'image/gif'],
|
||||
] as const) {
|
||||
await expect(detectImage(await raster(format)))
|
||||
.resolves.toEqual({ mediaType, width: 3, height: 2 })
|
||||
}
|
||||
})
|
||||
|
||||
it('detects both GIF revisions and rejects zero dimensions', () => {
|
||||
expect(detectImage(Uint8Array.from([...bytes('GIF87a'), 3, 0, 2, 0])))
|
||||
.toEqual({ mediaType: 'image/gif', width: 3, height: 2 })
|
||||
expect(detectImage(Uint8Array.from([...bytes('GIF89a'), 4, 0, 5, 0])))
|
||||
.toEqual({ mediaType: 'image/gif', width: 4, height: 5 })
|
||||
expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 0, 0, 1, 0])))
|
||||
.toThrow(/positive/)
|
||||
expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 1, 0, 0, 0])))
|
||||
.toThrow(/positive/)
|
||||
it('rejects excess decoded pixels before decoding', async () => {
|
||||
await expect(detectImage(await raster('png'), 5))
|
||||
.rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
})
|
||||
|
||||
it('walks JPEG marker forms and reports malformed dimensions', () => {
|
||||
const sof = [0xff, 0xc0, 0, 7, 8, 0, 2, 0, 3]
|
||||
expect(detectImage(Uint8Array.from([0xff, 0xd8, ...sof])))
|
||||
.toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 })
|
||||
expect(detectImage(Uint8Array.from([
|
||||
0xff, 0xd8,
|
||||
0xe0, 0, 2,
|
||||
0x01,
|
||||
0xff, ...sof,
|
||||
]))).toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 })
|
||||
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xd9, 0, 0, 0])))
|
||||
.toThrow(/missing/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xff, 0xff, 0xff, 0xff])))
|
||||
.toThrow(/missing/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 1, 0])))
|
||||
.toThrow(/truncated/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 9, 0])))
|
||||
.toThrow(/truncated/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xc0, 0, 6, 0, 0, 0, 0])))
|
||||
.toThrow(/dimensions are truncated/)
|
||||
})
|
||||
|
||||
it('detects each WebP header and rejects truncated or unknown chunks', () => {
|
||||
expect(detectImage(webp('VP8X', (data) => {
|
||||
data.set([2, 0, 0], 24)
|
||||
data.set([3, 0, 0], 27)
|
||||
}))).toEqual({ mediaType: 'image/webp', width: 3, height: 4 })
|
||||
|
||||
expect(detectImage(webp('VP8L', (data) => {
|
||||
data[20] = 0x2f
|
||||
data.set([2, 0, 1, 0], 21)
|
||||
}))).toEqual({ mediaType: 'image/webp', width: 3, height: 5 })
|
||||
|
||||
expect(detectImage(webp('VP8 ', (data) => {
|
||||
data.set([0x9d, 0x01, 0x2a], 23)
|
||||
data.set([6, 0, 7, 0], 26)
|
||||
}))).toEqual({ mediaType: 'image/webp', width: 6, height: 7 })
|
||||
|
||||
const truncated = webp('VP8X', () => {})
|
||||
truncated[4] = 23
|
||||
expect(() => detectImage(truncated)).toThrow(/truncated/)
|
||||
expect(() => detectImage(webp('NOPE', () => {}))).toThrow(/dimensions are missing/)
|
||||
expect(() => detectImage(webp('VP8L', () => {}))).toThrow(/dimensions are missing/)
|
||||
expect(() => detectImage(webp('VP8 ', () => {}))).toThrow(/dimensions are missing/)
|
||||
})
|
||||
|
||||
it('rejects unrecognized bytes and near-miss signatures', () => {
|
||||
expect(() => detectImage(new Uint8Array(0))).toThrow(/Unsupported/)
|
||||
expect(() => detectImage(Uint8Array.from([...bytes('GIFxxa'), 1, 0, 1, 0])))
|
||||
.toThrow(/Unsupported/)
|
||||
const nearWebp = webp('VP8X', () => {})
|
||||
nearWebp[8] = 0
|
||||
expect(() => detectImage(nearWebp)).toThrow(/Unsupported/)
|
||||
it('rejects malformed bytes and truncated payloads with readable headers', async () => {
|
||||
await expect(detectImage(Uint8Array.of(1, 2, 3)))
|
||||
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const unsupported = await sharp({
|
||||
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).tiff().toBuffer()
|
||||
await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const complete = await raster('png')
|
||||
const truncated = complete.subarray(0, 62)
|
||||
await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 })
|
||||
await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,13 +42,13 @@ describe('local attachment service', () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
|
||||
try {
|
||||
const service = new LocalAttachmentStore(new Context(), { dshHome })
|
||||
expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) })
|
||||
.toThrow(/Unsupported or malformed image data/)
|
||||
await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }))
|
||||
.rejects.toThrow(/Unsupported or malformed image data/)
|
||||
const valid = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow()
|
||||
await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined()
|
||||
expect(existsSync(service.root)).toBe(false)
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import { readImageFile, saveImageFile } from '../src/store.ts'
|
||||
|
||||
@@ -122,8 +123,9 @@ describe('local attachment store', () => {
|
||||
data: PNG, mediaType: 'image/png',
|
||||
}, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
|
||||
|
||||
const wide = PNG.slice()
|
||||
wide.set([0, 0, 0, 5, 0, 0, 0, 5], 16)
|
||||
const wide = new Uint8Array(await sharp({
|
||||
create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).png().toBuffer())
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: wide, mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
|
||||
@@ -37,8 +37,9 @@ export abstract class AttachmentStore extends Service {
|
||||
* Validate one image without persisting it.
|
||||
* Batch callers validate every member before saving any member.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
* @returns completion after the encoded raster has been fully decoded.
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): void
|
||||
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ImageAttachmentRef {
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Deployment-resolved limits shared by upload consumers and UI preflight. */
|
||||
/** Deployment-resolved limits used by upload admission and request buffering. */
|
||||
export interface ImageAttachmentLimits {
|
||||
maxImageBytes: number
|
||||
maxImagesPerMessage: number
|
||||
@@ -45,7 +45,7 @@ export interface ImageAttachmentLimits {
|
||||
/** Request to validate and durably commit one image. */
|
||||
export interface SaveImageAttachment {
|
||||
data: Uint8Array
|
||||
/** Caller-declared media type, checked against magic bytes. */
|
||||
/** Caller-declared media type, checked against fully decoded bytes. */
|
||||
mediaType: ImageMediaType
|
||||
/** Optional browser/provider display name; it is never interpreted as a path. */
|
||||
name?: string
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 74137edccc3ca68c40afd545350ce3811d6ae2e2
|
||||
README.zh.md: c974640ba08451d0a666061c6ec46ca88d66a85e
|
||||
README.md: ad64511120e3d4308ab03bb45de21b7d577b4335
|
||||
README.zh.md: 69926a282dea3f13564fe97e75d0c7c937b86335
|
||||
|
||||
@@ -22,9 +22,9 @@ Per-session UI state for selection and the active view lives in the declared cha
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
|
||||
Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input.
|
||||
Image drafts keep only ordered `DraftAttachmentId` values in that store. `ConversationService` owns the corresponding browser `File` and object URLs, rejects unsupported declared image media types before allocating previews, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. A historical read that completes after its rendered session or the service is disposed rejects before allocating an object URL. Paste and drop share the same validation path; mixed clipboard text remains native textarea input.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is limited to `apply`/`inject` and contract types; concrete services, implementation components (skeleton, chat rows), and the store factory stay internal. Same-package tests import those internals through `./src/*`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
|
||||
图片草稿在该 store 中只保留有序的运行时 id。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,在分配前应用最新的宿主能力与上传限制快照,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。
|
||||
图片草稿在该 store 中只保留有序的 `DraftAttachmentId` 值。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,会在分配预览前拒绝声明媒体类型不受支持的图片,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。一项历史读取如果在其所渲染的会话卸载或该服务释放后才完成,会在分配对象 URL 前被拒绝。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层仅限 `apply`/`inject` 与契约类型;具体服务、实现组件(骨架、聊天行)和 store factory 均保持内部状态。同包测试通过 `./src/*` 导入这些内部实现。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -51,6 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
|
||||
@@ -6,14 +6,14 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { ComposerKeyboard, DraftAttachmentId, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
/** Browser-owned image that has not crossed the durable host boundary. */
|
||||
export interface ComposerAttachment {
|
||||
kind: 'image'
|
||||
id: string
|
||||
id: DraftAttachmentId
|
||||
file: File
|
||||
previewUrl: string
|
||||
}
|
||||
@@ -275,9 +275,9 @@ export interface ComposerBarInjected {
|
||||
/** Create browser previews and append their ids to the session input state. */
|
||||
addImages: (files: readonly File[]) => string | null
|
||||
/** Release one browser preview and remove its id from the session input state. */
|
||||
removeImage: (id: string) => void
|
||||
removeImage: (id: DraftAttachmentId) => void
|
||||
/** Resolve ordered input-state ids to browser-owned draft attachments. */
|
||||
draftImages: (ids: readonly string[]) => readonly ComposerAttachment[]
|
||||
draftImages: (ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
export type { DraftAttachmentId } from './input/contract.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
* (machine.ts) is package-private and never exported.
|
||||
*/
|
||||
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** Browser-runtime identity of one unsent image draft. */
|
||||
export type DraftAttachmentId = Branded<'DraftAttachmentId'>
|
||||
|
||||
/**
|
||||
* The scoped-event application verbs: the hub's bail listeners call these,
|
||||
* and the boolean answer IS the event's bail value (true ⟺ the machine
|
||||
@@ -28,11 +32,11 @@ export interface SessionInput extends InputTarget {
|
||||
/** Single write path for draft text (all mutation rides machine events). */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned draft attachment ids. */
|
||||
addImages(ids: readonly string[]): void
|
||||
addImages(ids: readonly DraftAttachmentId[]): void
|
||||
/** Remove one browser-owned draft attachment id. */
|
||||
removeImage(id: string): void
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser objects no longer exist. */
|
||||
pruneImages(ids: readonly string[]): void
|
||||
pruneImages(ids: readonly DraftAttachmentId[]): void
|
||||
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
|
||||
submit(mode?: 'queue' | 'steer'): void
|
||||
/**
|
||||
@@ -65,11 +69,11 @@ export interface InputActions {
|
||||
/** Single public draft write path (full next draft; occurrence math via diff scan). */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned draft attachment ids. */
|
||||
addImages(ids: readonly string[]): void
|
||||
addImages(ids: readonly DraftAttachmentId[]): void
|
||||
/** Remove one browser-owned draft attachment id. */
|
||||
removeImage(id: string): void
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser objects no longer exist. */
|
||||
pruneImages(ids: readonly string[]): void
|
||||
pruneImages(ids: readonly DraftAttachmentId[]): void
|
||||
/** Enter submission (adjudication / claim transaction / default sink inside). */
|
||||
submit(mode?: 'queue' | 'steer'): void
|
||||
}
|
||||
@@ -198,7 +202,7 @@ export interface InputMachineOptions {
|
||||
export interface InputState {
|
||||
readonly draft: string
|
||||
/** Ordered runtime-only image ids; bytes and object URLs stay in ConversationService. */
|
||||
readonly imageIds: readonly string[]
|
||||
readonly imageIds: readonly DraftAttachmentId[]
|
||||
/** Monotonic draft revision (span CAS compares against this). */
|
||||
readonly draftRev: number
|
||||
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ReferenceInsert, SlashController, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type {
|
||||
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
|
||||
} from './contract.ts'
|
||||
import { InputMachine } from './machine.ts'
|
||||
@@ -39,7 +39,7 @@ export interface SessionInputDeps {
|
||||
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
|
||||
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
|
||||
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
|
||||
defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly string[]): void
|
||||
defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly DraftAttachmentId[]): void
|
||||
}
|
||||
|
||||
/** Guard tier from the machine phase. */
|
||||
@@ -79,7 +79,7 @@ export class SessionInputShell implements SessionInput {
|
||||
private readonly core = new InputMachine({ now: () => Date.now() })
|
||||
private noticeSeq = 0
|
||||
private lastDraft = ''
|
||||
private imageIds: readonly string[] = []
|
||||
private imageIds: readonly DraftAttachmentId[] = []
|
||||
private disposed = false
|
||||
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
|
||||
private mirrorFn: ((text: string) => void) | undefined
|
||||
@@ -102,14 +102,14 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
/** Append ordered browser-owned draft attachment ids. */
|
||||
addImages(ids: readonly string[]): void {
|
||||
addImages(ids: readonly DraftAttachmentId[]): void {
|
||||
if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return
|
||||
this.imageIds = [...this.imageIds, ...ids]
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Remove one browser-owned draft attachment id. */
|
||||
removeImage(id: string): void {
|
||||
removeImage(id: DraftAttachmentId): void {
|
||||
const next = this.imageIds.filter(candidate => candidate !== id)
|
||||
if (next.length === this.imageIds.length) return
|
||||
this.imageIds = next
|
||||
@@ -120,7 +120,7 @@ export class SessionInputShell implements SessionInput {
|
||||
* Drop ids whose browser objects no longer exist.
|
||||
* @param available - ids that still resolve through the browser attachment registry.
|
||||
*/
|
||||
pruneImages(available: readonly string[]): void {
|
||||
pruneImages(available: readonly DraftAttachmentId[]): void {
|
||||
const keep = new Set(available)
|
||||
const next = this.imageIds.filter(id => keep.has(id))
|
||||
if (next.length === this.imageIds.length) return
|
||||
@@ -132,7 +132,7 @@ export class SessionInputShell implements SessionInput {
|
||||
* Restore a failed attempt's ids before any images added after submission.
|
||||
* @param ids - ordered identifiers captured by the failed attempt.
|
||||
*/
|
||||
restoreImages(ids: readonly string[]): void {
|
||||
restoreImages(ids: readonly DraftAttachmentId[]): void {
|
||||
const current = new Set(this.imageIds)
|
||||
this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds]
|
||||
this.publish()
|
||||
@@ -144,7 +144,7 @@ export class SessionInputShell implements SessionInput {
|
||||
* (the command path gets the same discipline from submit-settled success).
|
||||
* @param imageIds - identifiers included in the committed attempt.
|
||||
*/
|
||||
commitSend(imageIds: readonly string[]): void {
|
||||
commitSend(imageIds: readonly DraftAttachmentId[]): void {
|
||||
const submitted = new Set(imageIds)
|
||||
this.imageIds = this.imageIds.filter(id => !submitted.has(id))
|
||||
this.run(this.core.dispatch({ type: 'send-committed' }))
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
import { SessionInputShell } from './facade.ts'
|
||||
|
||||
@@ -26,9 +26,9 @@ interface ConversationAttachmentFace {
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
mode: 'queue' | 'steer',
|
||||
imageIds: readonly string[],
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
): Promise<void>
|
||||
releaseDraftImage(id: string): void
|
||||
releaseDraftImage(id: DraftAttachmentId): void
|
||||
}
|
||||
|
||||
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
|
||||
@@ -138,7 +138,7 @@ export class InputHub implements InputService {
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
mode: 'queue' | 'steer',
|
||||
imageIds: readonly string[],
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
): void {
|
||||
if (text === '' && imageIds.length === 0) return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Context } from 'cordis'
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ComposerAttachment } from './contract/slots.ts'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
import type { DraftAttachmentId, InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
* The outward conversation face (`ctx.conversation`): the scope-addressed
|
||||
@@ -46,7 +46,7 @@ export interface IConversation {
|
||||
|
||||
/** Create one browser-only draft descriptor; only its id enters input state. */
|
||||
function browserDraftAttachment(file: File): ComposerAttachment {
|
||||
return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file }
|
||||
return { kind: 'image', id: crypto.randomUUID() as DraftAttachmentId, previewUrl: URL.createObjectURL(file), file }
|
||||
}
|
||||
|
||||
interface ImageUrlEntry {
|
||||
@@ -59,10 +59,11 @@ interface ImageUrlEntry {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputService
|
||||
private readonly draftAttachments = new Map<string, ComposerAttachment>()
|
||||
private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
|
||||
private readonly imageUrls = new Map<string, ImageUrlEntry>()
|
||||
private readonly imageGenerations = new Map<SessionId, number>()
|
||||
private readonly createdImageUrls = new Set<string>()
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
@@ -74,6 +75,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config.input
|
||||
ctx.effect(() => () => {
|
||||
this.disposed = true
|
||||
for (const url of this.createdImageUrls) URL.revokeObjectURL(url)
|
||||
this.createdImageUrls.clear()
|
||||
this.draftAttachments.clear()
|
||||
@@ -107,7 +109,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
session: SessionFace,
|
||||
text: string,
|
||||
mode: 'queue' | 'steer',
|
||||
imageIds: readonly string[],
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
): Promise<void> {
|
||||
const attachments = this.draftImages(imageIds)
|
||||
if (attachments.length !== imageIds.length) {
|
||||
@@ -149,7 +151,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
* @param ids - ordered ids from the per-session input state.
|
||||
* @returns attachments still available in this browser runtime.
|
||||
*/
|
||||
draftImages(ids: readonly string[]): readonly ComposerAttachment[] {
|
||||
draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] {
|
||||
const attachments: ComposerAttachment[] = []
|
||||
for (const id of ids) {
|
||||
const attachment = this.draftAttachments.get(id)
|
||||
@@ -162,7 +164,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
* Release one draft attachment preview.
|
||||
* @param id - draft-local attachment id.
|
||||
*/
|
||||
releaseDraftImage(id: string): void {
|
||||
releaseDraftImage(id: DraftAttachmentId): void {
|
||||
const attachment = this.draftAttachments.get(id)
|
||||
if (attachment === undefined) return
|
||||
this.draftAttachments.delete(id)
|
||||
@@ -185,6 +187,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
* @returns a browser URL for inline and original-size display.
|
||||
*/
|
||||
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
|
||||
if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed'))
|
||||
const key = `${sessionId}:${attachment.attachmentId}`
|
||||
const cached = this.imageUrls.get(key)
|
||||
if (cached !== undefined) return cached.pending
|
||||
@@ -194,6 +197,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
const pending = session.readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed')
|
||||
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
|
||||
throw new Error('historical image scope was released before loading completed')
|
||||
}
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
|
||||
}
|
||||
@@ -201,10 +208,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
const url = URL.createObjectURL(new Blob([bytes.buffer], {
|
||||
type: result.value.attachment.mediaType,
|
||||
}))
|
||||
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
|
||||
revokePreview(url)
|
||||
throw new Error('historical image scope was released before loading completed')
|
||||
}
|
||||
this.createdImageUrls.add(url)
|
||||
return url
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -78,7 +79,7 @@ function bench(over?: BenchOptions) {
|
||||
promptError: over?.promptError ?? null,
|
||||
}))
|
||||
const stop = vi.fn()
|
||||
const removeImage = vi.fn((id: string) => { shell.removeImage(id) })
|
||||
const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
|
||||
const slotCalls: { key: string; owner: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, owner })
|
||||
@@ -523,7 +524,7 @@ describe('image draft rail', () => {
|
||||
|
||||
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
|
||||
const send = view.getByRole('button', { name: 'Send message' }) as HTMLButtonElement
|
||||
expect(send.disabled).toBe(false)
|
||||
@@ -536,7 +537,7 @@ describe('image draft rail', () => {
|
||||
|
||||
it('opens the original preview on double-click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
import { ConversationService } from '../src/client/service.ts'
|
||||
|
||||
async function bench() {
|
||||
async function bench(readAttachment?: SessionFace['readAttachment']) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { prompt, cancel, loadOlder },
|
||||
session: { prompt, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
@@ -25,7 +27,7 @@ async function bench() {
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
return { runtime, hub, root, scoped, prompt, cancel, loadOlder }
|
||||
return { runtime, fiber, hub, root, scoped, prompt, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
@@ -122,6 +124,37 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('does not publish a historical image URL after disposal', async () => {
|
||||
let resolveRead!: (result: Awaited<ReturnType<SessionFace['readAttachment']>>) => void
|
||||
const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise<Awaited<ReturnType<SessionFace['readAttachment']>>>(
|
||||
(resolve) => { resolveRead = resolve },
|
||||
))
|
||||
const b = await bench(readAttachment)
|
||||
const created = vi.spyOn(URL, 'createObjectURL')
|
||||
const sessionId = b.runtime.sessions.behavior('s1').sessionId
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId('image-1'),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
} as const
|
||||
const pending = b.root.resolveImage(sessionId, attachment)
|
||||
await b.fiber.dispose()
|
||||
await expect(b.root.resolveImage(sessionId, attachment)).rejects.toThrow('service is disposed')
|
||||
resolveRead({
|
||||
ok: true,
|
||||
value: {
|
||||
attachment,
|
||||
data: Uint8Array.of(1),
|
||||
},
|
||||
})
|
||||
await expect(pending).rejects.toThrow('service was disposed before loading completed')
|
||||
expect(created).not.toHaveBeenCalled()
|
||||
created.mockRestore()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -161,8 +161,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: 'Immutable binary attachment service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract validateImage(input: SaveImageAttachment): void',
|
||||
jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */',
|
||||
signature: 'abstract validateImage(input: SaveImageAttachment): Promise<void>',
|
||||
jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns completion after the encoded raster has been fully decoded.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 1f0daedc54888a1951bc83c474f83287aaf42307
|
||||
README.zh.md: abf5417cdbe93f1199c621ac101249986969da93
|
||||
README.md: 693b2c26a9ec1e7ea31030a3028f05706adbfc3b
|
||||
README.zh.md: b1766d901d9ed5743cbc766166bfb310c565ef5f
|
||||
|
||||
@@ -14,7 +14,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行;当图片正等待发布或仍存在于当前派生历史中时,会拒绝选择纯文本目标;被压缩(compaction)移除的图片不再阻止选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
|
||||
@@ -92,30 +92,29 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
|
||||
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
for (const image of images) {
|
||||
ctx.attachments.validateImage({
|
||||
await ctx.attachments.validateImage({
|
||||
data: image.data,
|
||||
mediaType: image.part.mediaType,
|
||||
...image.part.name === undefined ? {} : { name: image.part.name },
|
||||
})
|
||||
}
|
||||
return Promise.all(prepared.map(async (item): Promise<ContentBlock> => {
|
||||
if (!('data' in item)) return { type: 'text', text: item.text }
|
||||
const blocks: ContentBlock[] = []
|
||||
for (const item of prepared) {
|
||||
if (!('data' in item)) {
|
||||
blocks.push({ type: 'text', text: item.text })
|
||||
continue
|
||||
}
|
||||
const attachment = await ctx.attachments.saveImage({
|
||||
data: item.data,
|
||||
mediaType: item.part.mediaType,
|
||||
...item.part.name === undefined ? {} : { name: item.part.name },
|
||||
})
|
||||
return { type: 'image', attachment }
|
||||
}))
|
||||
blocks.push({ type: 'image', attachment })
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE recursive block walk shared by attachment authorization and the
|
||||
* model-selection gate (nested tool-result content included). Both consumers
|
||||
* must agree on what counts as replayed image content — a route added to one
|
||||
* walker but not the other would silently skip authorization or stranding
|
||||
* protection — so there is exactly one walker, parameterized by match.
|
||||
*/
|
||||
/** Search durable event content for an image reference, including nested tool results. */
|
||||
function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
for (const value of content) {
|
||||
@@ -148,18 +147,15 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** True when any block (nested tool-result content included) is an image block. */
|
||||
function contentHasImage(content: unknown): boolean {
|
||||
return imageBlockIn(content, () => true) !== undefined
|
||||
/** True when typed model content contains an image, including nested tool results. */
|
||||
function contentHasImage(content: readonly ContentBlock[]): boolean {
|
||||
return content.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && contentHasImage(block.content)))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the session log already carries image content on any route a
|
||||
* model request replays (message content, wrapped messages, streamed blocks).
|
||||
* The log is immutable, so a true here is permanent for the session's life.
|
||||
*/
|
||||
function sessionHasImage(events: readonly SessionEvent[]): boolean {
|
||||
return events.some(event => imageInEvent(event, () => true) !== undefined)
|
||||
/** True when the current model-visible surface contains an image. */
|
||||
function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
|
||||
return messages.some(message => contentHasImage(message.content))
|
||||
}
|
||||
|
||||
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
|
||||
@@ -564,6 +560,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const pendingApprovals = new Map<RpcId, PendingApproval>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
const imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
|
||||
|
||||
/** Serialize model selection with image prompt admission for one agent. */
|
||||
function serializeImageAdmission<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
|
||||
const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
|
||||
imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or return the session-local target that prompt assembly snapshots.
|
||||
@@ -619,18 +623,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
|
||||
* (the same refresh-recovery baseline as pending questions). Each terminal
|
||||
* inbox event retires one matching occurrence, so repeated sends of the same
|
||||
* identified message remain visible until every occurrence is claimed.
|
||||
* identified message remain visible until every occurrence is published or
|
||||
* discarded. Dequeue is not publication: the log append follows it.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
|
||||
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean; claimed: boolean }[]>()
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
const retire = (sessionId: SessionId, id: MessageId, placement?: InboxPlacement): void => {
|
||||
const entries = queuedMirror.get(sessionId)
|
||||
if (entries === undefined) return
|
||||
const index = entries.findIndex(entry =>
|
||||
entry.message.id === id
|
||||
&& (placement === undefined || entry.steering === (placement === 'steering')))
|
||||
if (index !== -1) entries.splice(index, 1)
|
||||
if (entries.length === 0) queuedMirror.delete(agent.id)
|
||||
if (entries.length === 0) queuedMirror.delete(sessionId)
|
||||
}
|
||||
const retireClaimed = (sessionId: SessionId): void => {
|
||||
const entries = queuedMirror.get(sessionId)
|
||||
if (entries === undefined) return
|
||||
const pending = entries.filter(entry => !entry.claimed)
|
||||
if (pending.length === 0) queuedMirror.delete(sessionId)
|
||||
else queuedMirror.set(sessionId, pending)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
|
||||
@@ -640,7 +652,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queuedMirror.set(agent.id, entries)
|
||||
}
|
||||
const steering = placement === 'steering'
|
||||
entries.push({ message, steering })
|
||||
entries.push({ message, steering, claimed: false })
|
||||
broadcast({
|
||||
type: 'session/queued',
|
||||
sessionId: agent.id,
|
||||
@@ -649,10 +661,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => {
|
||||
retire(agent, message.id, placement)
|
||||
// A later claim proves any earlier claimed item either published (and
|
||||
// was retired by session/event) or its admission ended without one.
|
||||
retireClaimed(agent.id)
|
||||
const entry = queuedMirror.get(agent.id)?.find(candidate =>
|
||||
candidate.message.id === message.id
|
||||
&& candidate.steering === (placement === 'steering'))
|
||||
if (entry !== undefined) entry.claimed = true
|
||||
}),
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type === 'user/message') {
|
||||
retire(session.id, event.data.id, 'queued')
|
||||
} else if (event.type === 'steering/message') {
|
||||
retire(session.id, (event.data as { message: UserMessage }).message.id, 'steering')
|
||||
}
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => {
|
||||
for (const message of messages) retire(agent, message.id)
|
||||
for (const message of messages) retire(agent.id, message.id)
|
||||
}),
|
||||
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
|
||||
if (status === 'idle') retireClaimed(agent.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queuedMirror.delete(session.id)
|
||||
@@ -1133,48 +1161,47 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const { sessionId, provider, model, reasoningEffort } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const resolved = await ctx.llm.resolveCallConfig({
|
||||
provider,
|
||||
model,
|
||||
...reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
|
||||
})
|
||||
// An image-bearing log replays into every later request, and both
|
||||
// wire routes reject image content on text-only models — accepting
|
||||
// this selection would strand the session (every turn fails, no
|
||||
// in-product recovery). Refuse at the selection boundary instead.
|
||||
// The pending inbox counts too: a queued image prompt enters the log
|
||||
// only when claimed, which would happen AFTER this switch landed.
|
||||
const queuedImage = (queuedMirror.get(sessionId) ?? [])
|
||||
.some(entry => contentHasImage(entry.message.content))
|
||||
if (queuedImage || sessionHasImage(found.agent.session.events)) {
|
||||
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
|
||||
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`,
|
||||
details: { provider, model },
|
||||
})
|
||||
return serializeImageAdmission(found.agent, async () => {
|
||||
try {
|
||||
const resolved = await ctx.llm.resolveCallConfig({
|
||||
provider,
|
||||
model,
|
||||
...reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
|
||||
})
|
||||
// A current image-bearing surface replays into the next request,
|
||||
// while a dequeued prompt remains pending until its message event
|
||||
// publishes. Refuse a text-only route at this shared boundary.
|
||||
const queuedImage = (queuedMirror.get(sessionId) ?? [])
|
||||
.some(entry => contentHasImage(entry.message.content))
|
||||
if (queuedImage || messagesHaveImage(found.agent.session.deriveMessages())) {
|
||||
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
|
||||
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`,
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
}
|
||||
const selected: AgentLlmTarget = {
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
...resolved.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: resolved.reasoningEffort },
|
||||
}
|
||||
targetFor(found.agent).current = selected
|
||||
return ok(request, { selected: { ...selected } })
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
const selected: AgentLlmTarget = {
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
...resolved.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: resolved.reasoningEffort },
|
||||
}
|
||||
targetFor(found.agent).current = selected
|
||||
return ok(request, { selected: { ...selected } })
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async rename(request) {
|
||||
@@ -1214,36 +1241,40 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const agent = found.agent
|
||||
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
if (content.some(part => part.type === 'image')) {
|
||||
const target = targetFor(agent).current
|
||||
const provider = target.provider
|
||||
const model = target.model
|
||||
const modelInfo = await ctx.llm.resolveModelInfo(provider, model)
|
||||
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
|
||||
const hasImage = content.some(part => part.type === 'image')
|
||||
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
|
||||
try {
|
||||
if (hasImage) {
|
||||
const target = targetFor(agent).current
|
||||
const provider = target.provider
|
||||
const model = target.model
|
||||
const modelInfo = await ctx.llm.resolveModelInfo(provider, model)
|
||||
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: `Model "${model}" does not support image input.`,
|
||||
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||
})
|
||||
}
|
||||
}
|
||||
const durable = await durablePromptContent(ctx, content)
|
||||
const message: UserMessage = createUserMessage({ content: durable, source })
|
||||
if (mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AttachmentError) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: `Model "${model}" does not support image input.`,
|
||||
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||
message: error.message,
|
||||
details: { reason: error.code },
|
||||
})
|
||||
}
|
||||
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
}
|
||||
const durable = await durablePromptContent(ctx, content)
|
||||
const message: UserMessage = createUserMessage({ content: durable, source })
|
||||
if (mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AttachmentError) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: error.message,
|
||||
details: { reason: error.code },
|
||||
})
|
||||
}
|
||||
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
return hasImage ? serializeImageAdmission(agent, admit) : admit()
|
||||
},
|
||||
|
||||
async attachment(request) {
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('session/queued frames', () => {
|
||||
expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('retires mirror entries on their terminal dequeue', async () => {
|
||||
it('retains each dequeued entry until its durable message publishes', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
@@ -311,15 +311,50 @@ describe('session/queued frames', () => {
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
|
||||
|
||||
const pendingAbort = new AbortController()
|
||||
const pending = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-dequeued'), payload: {} }, pendingAbort.signal), 3, pendingAbort)
|
||||
expect(pending.filter(f => f.type === 'session/queued')).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
|
||||
])
|
||||
|
||||
agent.session.append('user/message', queued, { surfaceOp: 'append' })
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
|
||||
agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' })
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retires the matching placement when one message identity is queued and steering', async () => {
|
||||
it('retires claimed entries whose admission ends without publication', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const rejected = inboxMessage('m-rejected', 'rejected')
|
||||
const successor = inboxMessage('m-successor', 'successor')
|
||||
ctx.emit('agent/inbox/enqueue', agent, rejected, 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, rejected, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, successor, 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, successor, 'queued')
|
||||
|
||||
const pendingAbort = new AbortController()
|
||||
const pending = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-rejected'), payload: {} }, pendingAbort.signal), 2, pendingAbort)
|
||||
expect(pending.filter(f => f.type === 'session/queued')).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: successor, steering: false },
|
||||
])
|
||||
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
const idleAbort = new AbortController()
|
||||
const idle = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-rejected-idle'), payload: {} }, idleAbort.signal), 1, idleAbort)
|
||||
expect(idle.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retires the matching published placement when one message identity is queued and steering', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
@@ -328,6 +363,7 @@ describe('session/queued frames', () => {
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
|
||||
agent.session.append('steering/message', { turn: 1, message: repeated }, { surfaceOp: 'append' })
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
|
||||
@@ -117,10 +117,26 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function registerTextOnly(ctx: Context): void {
|
||||
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
||||
}
|
||||
}('Text Only', []))
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const validateImage = vi.fn((_input: { data: Uint8Array }): void => {})
|
||||
let secondValidationStarted!: () => void
|
||||
let releaseSecondValidation!: () => void
|
||||
const secondStarted = new Promise<void>((resolve) => { secondValidationStarted = resolve })
|
||||
const secondReleased = new Promise<void>((resolve) => { releaseSecondValidation = resolve })
|
||||
const validateImage = vi.fn(async (input: { data: Uint8Array }): Promise<void> => {
|
||||
if (input.data[0] !== 2) return
|
||||
secondValidationStarted()
|
||||
await secondReleased
|
||||
})
|
||||
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => {
|
||||
return Promise.resolve({
|
||||
attachmentId: `att-${String(input.data[0])}`,
|
||||
@@ -141,11 +157,15 @@ describe('Web session model selection', () => {
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' }
|
||||
const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' }
|
||||
const accepted = await api.sessions.prompt(request({
|
||||
const accepting = api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [first, { type: 'text' as const, text: 'compare' }, second],
|
||||
}))
|
||||
await secondStarted
|
||||
expect(saveImage).not.toHaveBeenCalled()
|
||||
releaseSecondValidation()
|
||||
const accepted = await accepting
|
||||
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
|
||||
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
|
||||
@@ -294,13 +314,9 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a text-only selection once the session log carries an image', async () => {
|
||||
it('refuses a text-only selection while current derived history carries an image', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
||||
}
|
||||
}('Text Only', []))
|
||||
registerTextOnly(ctx)
|
||||
ctx.llm.registerAdapter(['vision'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
|
||||
@@ -318,7 +334,7 @@ describe('Web session model selection', () => {
|
||||
content: [{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
|
||||
} as never, { surfaceOp: 'append' })
|
||||
|
||||
// The log is immutable: a text-only route would fail every later turn.
|
||||
// The image remains on the current request surface, so a text-only route would fail the next turn.
|
||||
const stranded = await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))
|
||||
@@ -337,31 +353,87 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => {
|
||||
it('keeps a dequeued image pending until publication, then follows the compacted surface', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
||||
}
|
||||
}('Text Only', []))
|
||||
registerTextOnly(ctx)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
// The queued message enters the session log only when claimed — after a
|
||||
// model switch would already have landed. The pending-inbox mirror must
|
||||
// therefore gate the switch too.
|
||||
ctx.emit('agent/inbox/enqueue', agent, {
|
||||
const queued = {
|
||||
id: 'q-1', role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
|
||||
} as never, 'queued')
|
||||
const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
|
||||
expect(stranded.result.ok).toBe(false)
|
||||
// Claiming the message drains the mirror; the log now owns the decision.
|
||||
ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued')
|
||||
} as never
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
|
||||
// Dequeue precedes the authoritative append, so it cannot open a switch window.
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
|
||||
const imageEvent = agent.session.append('user/message', queued, { surfaceOp: 'append' })
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
|
||||
// Publication retires the mirror; once compaction shadows the image, the
|
||||
// current model-visible surface no longer requires an image-capable route.
|
||||
agent.session.append('user/message', {
|
||||
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
|
||||
content: [{ type: 'text', text: 'image summarized' }],
|
||||
} as never, {
|
||||
surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
|
||||
sourceEventSeqs: [imageEvent.seq],
|
||||
})
|
||||
expect(expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serializes an image save with a concurrent model selection', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
registerTextOnly(ctx)
|
||||
let saveStarted!: () => void
|
||||
let releaseSave!: () => void
|
||||
const started = new Promise<void>((resolve) => { saveStarted = resolve })
|
||||
const released = new Promise<void>((resolve) => { releaseSave = resolve })
|
||||
const ref = { attachmentId: 'att-race', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: {
|
||||
maxImageBytes: 1,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
validateImage: () => Promise.resolve(),
|
||||
saveImage: async () => {
|
||||
saveStarted()
|
||||
await released
|
||||
return ref
|
||||
},
|
||||
} as never)
|
||||
Object.assign(agent, {
|
||||
followup(message: UserMessage) {
|
||||
ctx.emit('agent/inbox/enqueue', agent, message, 'queued')
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const prompt = api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' }],
|
||||
}))
|
||||
await started
|
||||
const selection = api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
|
||||
expect(await Promise.race([
|
||||
selection.then(() => 'settled' as const),
|
||||
new Promise<'pending'>((resolve) => { setTimeout(() => { resolve('pending') }, 0) }),
|
||||
])).toBe('pending')
|
||||
|
||||
releaseSave()
|
||||
expect((await prompt).result.ok).toBe(true)
|
||||
expect((await selection).result.ok).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('authorizes an attachment read referenced only from wrapped message content', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 }
|
||||
@@ -369,9 +441,8 @@ describe('Web session model selection', () => {
|
||||
readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
// The only reference lives inside an assistant/message wrapper — the same
|
||||
// walk that gates model selection must authorize the read, or a real host
|
||||
// denies galleries the fixture (with its own authorization mirror) serves.
|
||||
// The only reference lives inside an assistant/message wrapper; the
|
||||
// authorization walk must follow that durable event shape.
|
||||
agent.session.append('assistant/message', {
|
||||
turn: 1, step: 0,
|
||||
message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] },
|
||||
@@ -383,7 +454,7 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => {
|
||||
it('detects images in wrapped messages and nested tool results on the current surface', async () => {
|
||||
const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
|
||||
const cases: { label: string; append: (agent: Agent) => void }[] = [
|
||||
{
|
||||
@@ -394,14 +465,6 @@ describe('Web session model selection', () => {
|
||||
} as never, { surfaceOp: 'append' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'streamed assistant block',
|
||||
append: (agent) => {
|
||||
agent.session.append('assistant/chunk', {
|
||||
turn: 1, step: 0, chunk: { type: 'block-end', index: 0, block: image },
|
||||
} as never)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'nested tool-result content',
|
||||
append: (agent) => {
|
||||
@@ -414,11 +477,7 @@ describe('Web session model selection', () => {
|
||||
]
|
||||
for (const { label, append } of cases) {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
||||
}
|
||||
}('Text Only', []))
|
||||
registerTextOnly(ctx)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
append(agent)
|
||||
const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
|
||||
README.md: 3cd64b8170ac0f6b6c4316f19bb816c65c223935
|
||||
README.zh.md: 478ccb91df996aaf67bd952e95596f4ea65564bc
|
||||
README.md: 885a2dcbc6fea3c21421d83202941f8251bc3c06
|
||||
README.zh.md: d5ea4b80bdc2e0655994667cbd099af7d20aefe9
|
||||
|
||||
@@ -45,7 +45,7 @@ Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reason
|
||||
|
||||
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
|
||||
|
||||
Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent.
|
||||
Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. Image detection and conversion recurse through nested `tool-result` content, so a nested image is neither flattened nor skipped. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent.
|
||||
|
||||
## Provider/model routing and replay
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
|
||||
|
||||
图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。
|
||||
图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。图片检测与转换会递归遍历嵌套的 `tool-result` 内容,因此嵌套图片既不会被展平,也不会被跳过。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。
|
||||
|
||||
## 提供方/模型路由与回放
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveProfiles } from './config.ts'
|
||||
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
import { toPiContext } from './context.ts'
|
||||
import { contentHasImage, toPiContext } from './context.ts'
|
||||
import { toStreamChunks } from './stream.ts'
|
||||
|
||||
/** Constructor options for {@link PiAiAdapter}. */
|
||||
@@ -192,8 +192,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
const containsImage = options.messages.some((message) => {
|
||||
// The discriminant is part of same-process message validity and is read before content.
|
||||
void message.role
|
||||
return message.content.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))
|
||||
return contentHasImage(message.content)
|
||||
})
|
||||
if (containsImage && !model.input.includes('image')) {
|
||||
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
|
||||
|
||||
@@ -18,6 +18,23 @@ function flattenText(message: Message): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether content contains an image, including nested tool results.
|
||||
* @param blocks - content to inspect recursively.
|
||||
* @returns whether any nested block is an image.
|
||||
*/
|
||||
export function contentHasImage(blocks: readonly ContentBlock[]): boolean {
|
||||
return blocks.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && contentHasImage(block.content)))
|
||||
}
|
||||
|
||||
/** Flatten text recursively inside one tool result. */
|
||||
function toolResultText(blocks: readonly ContentBlock[]): string {
|
||||
return blocks.map(block => block.type === 'text'
|
||||
? block.text
|
||||
: block.type === 'tool-result' ? toolResultText(block.content) : '').join('')
|
||||
}
|
||||
|
||||
async function userContent(
|
||||
blocks: readonly ContentBlock[],
|
||||
attachments: AttachmentStore,
|
||||
@@ -38,6 +55,14 @@ async function userContent(
|
||||
break
|
||||
}
|
||||
case 'tool-result':
|
||||
{
|
||||
const nested = await userContent(block.content, attachments)
|
||||
if (typeof nested === 'string') {
|
||||
if (nested.length > 0) content.push({ type: 'text', text: nested })
|
||||
} else {
|
||||
content.push(...nested)
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
// Other merge-extensible blocks are not user-input vocabulary for pi-ai.
|
||||
@@ -72,8 +97,7 @@ function textOnlyContext(options: GenerateOptions): PiContext {
|
||||
const toolNames = new Map<CallId, string>()
|
||||
const messages: PiMessage[] = []
|
||||
for (const message of options.messages) {
|
||||
if (message.content.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))) {
|
||||
if (contentHasImage(message.content)) {
|
||||
throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
if (message.role === 'system') {
|
||||
@@ -96,7 +120,7 @@ function textOnlyContext(options: GenerateOptions): PiContext {
|
||||
toolName: toolNames.get(result.toolCallId) ?? 'unknown',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: result.content.filter(block => block.type === 'text').map(block => block.text).join('') || '(no output)',
|
||||
text: toolResultText(result.content) || '(no output)',
|
||||
}],
|
||||
isError: result.isError ?? false,
|
||||
timestamp: 0,
|
||||
@@ -131,7 +155,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta
|
||||
|
||||
for (const message of options.messages) {
|
||||
if (message.role === 'system') {
|
||||
if (message.content.some(block => block.type === 'image')) {
|
||||
if (contentHasImage(message.content)) {
|
||||
throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
// pi-ai has a single systemPrompt slot; in-history system messages are
|
||||
|
||||
@@ -256,8 +256,8 @@ describe('PiAiAdapter provider routing', () => {
|
||||
mediaTypes: ['image/png'],
|
||||
}
|
||||
|
||||
validateImage(_input: SaveImageAttachment): void {
|
||||
throw new Error('not used')
|
||||
validateImage(_input: SaveImageAttachment): Promise<void> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
|
||||
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
@@ -613,8 +613,12 @@ describe('provider profile lifecycle', () => {
|
||||
messages: [createUserMessage({
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-image' as never,
|
||||
content: [{ type: 'image', attachment: IMAGE_REF }],
|
||||
toolCallId: 'call-outer' as never,
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-inner' as never,
|
||||
content: [{ type: 'image', attachment: IMAGE_REF }],
|
||||
}],
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
|
||||
@@ -97,6 +97,55 @@ describe('toPiContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('flattens nested tool-result images into the enclosing result', async () => {
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) })
|
||||
const context = await toPiContext({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
messages: [createUserMessage({
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId('outer'),
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: CallId('empty'), content: [] },
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'tool-result', toolCallId: CallId('text'), content: [{ type: 'text', text: 'middle' }] },
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId('inner'),
|
||||
content: [
|
||||
{ type: 'image', attachment },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
}, { readImage } as unknown as AttachmentStore)
|
||||
|
||||
expect(context.messages).toEqual([{
|
||||
role: 'toolResult',
|
||||
toolCallId: 'outer',
|
||||
toolName: 'unknown',
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'text', text: 'middle' },
|
||||
{ type: 'image', data: 'AQID', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects structured image history when no durable resolver is supplied', () => {
|
||||
expect(() => toPiContext({
|
||||
provider: 'openai', model: 'gpt-4.1',
|
||||
@@ -205,7 +254,15 @@ describe('toPiContext', () => {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}),
|
||||
createUserMessage({
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId('c1'),
|
||||
content: [
|
||||
{ type: 'text', text: 'Sunny' },
|
||||
{ type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'text', text: '!' }] },
|
||||
{ type: 'chart', data: 'ignored' } as unknown as ContentBlock,
|
||||
],
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}),
|
||||
],
|
||||
@@ -214,7 +271,7 @@ describe('toPiContext', () => {
|
||||
role: 'toolResult',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'get_weather',
|
||||
content: [{ type: 'text', text: 'Sunny' }],
|
||||
content: [{ type: 'text', text: 'Sunny!' }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
})
|
||||
|
||||
@@ -74,8 +74,8 @@ async function harness(image?: StoredImageAttachment): Promise<Context> {
|
||||
mediaTypes: [fixture.ref.mediaType],
|
||||
}
|
||||
|
||||
validateImage(_input: SaveImageAttachment): void {
|
||||
throw new Error('e2e attachment fixture is read-only')
|
||||
validateImage(_input: SaveImageAttachment): Promise<void> {
|
||||
return Promise.reject(new Error('e2e attachment fixture is read-only'))
|
||||
}
|
||||
|
||||
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
|
||||
Reference in New Issue
Block a user