fix(gui): harden multimodal image attachments
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-attachment-local
|
||||
|
||||
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, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata.
|
||||
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, and an atomic exclusive hard-link publish; 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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -11,31 +11,31 @@ export interface DetectedImage {
|
||||
}
|
||||
|
||||
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 ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0)
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset)
|
||||
}
|
||||
|
||||
function u16le(data: Uint8Array, offset: number): number {
|
||||
return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8)
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true)
|
||||
}
|
||||
|
||||
function u24le(data: Uint8Array, offset: number): number {
|
||||
return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 16)
|
||||
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 (((data[offset] ?? 0) * 0x1000000) + ((data[offset + 1] ?? 0) << 16)
|
||||
+ ((data[offset + 2] ?? 0) << 8) + (data[offset + 3] ?? 0)) >>> 0
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset)
|
||||
}
|
||||
|
||||
function u32le(data: Uint8Array, offset: number): number {
|
||||
return ((data[offset] ?? 0) + ((data[offset + 1] ?? 0) << 8)
|
||||
+ ((data[offset + 2] ?? 0) << 16) + ((data[offset + 3] ?? 0) * 0x1000000)) >>> 0
|
||||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true)
|
||||
}
|
||||
|
||||
function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage {
|
||||
@@ -86,10 +86,11 @@ export function detectImage(data: Uint8Array): DetectedImage {
|
||||
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 b0 = data[21] ?? 0
|
||||
const b1 = data[22] ?? 0
|
||||
const b2 = data[23] ?? 0
|
||||
const b3 = data[24] ?? 0
|
||||
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')
|
||||
}
|
||||
if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) {
|
||||
|
||||
@@ -67,7 +67,7 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
}
|
||||
|
||||
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref, this.imageLimits)
|
||||
return readImageFile(this.root, ref)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,15 +38,20 @@ function ensureReference(ref: ImageAttachmentRef): string {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): Omit<ImageAttachmentRef, 'attachmentId' | 'name'> {
|
||||
function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit<ImageAttachmentRef, 'attachmentId' | 'name'> {
|
||||
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
|
||||
if (data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
const detected = detectImage(data)
|
||||
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
|
||||
if (detected.width * detected.height > limits.maxImagePixels) throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and verify immutable image bytes below a versioned attachment root.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
@@ -55,7 +60,8 @@ function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRe
|
||||
* @returns durable content-addressed reference.
|
||||
*/
|
||||
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
|
||||
const metadata = validateMetadata(input.data, input.mediaType, limits)
|
||||
const metadata = inspectMetadata(input.data, input.mediaType)
|
||||
validateAdmission(metadata, limits)
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
@@ -75,16 +81,25 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
try {
|
||||
await link(temporary, target)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
|
||||
const existing = new Uint8Array(await readFile(target))
|
||||
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
await unlink(temporary)
|
||||
} catch (error) {
|
||||
if (handle !== undefined) await handle.close().catch(() => { /* close failure is superseded by the storage failure */ })
|
||||
await unlink(temporary).catch((cleanupError: unknown) => {
|
||||
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
|
||||
})
|
||||
/* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */
|
||||
if (handle !== undefined) await handle.close().catch(
|
||||
/* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */
|
||||
() => {},
|
||||
)
|
||||
await unlink(temporary).catch(
|
||||
/* v8 ignore next -- The callback requires a second independent staging-unlink failure. */
|
||||
(cleanupError: unknown) => {
|
||||
/* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */
|
||||
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
|
||||
},
|
||||
)
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
|
||||
}
|
||||
@@ -100,10 +115,9 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
* Read and verify one content-addressed image.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param ref - reference recorded in the session log.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns verified bytes and reference.
|
||||
*/
|
||||
export async function readImageFile(root: string, ref: ImageAttachmentRef, limits: ImageAttachmentLimits): Promise<StoredImageAttachment> {
|
||||
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
const sha256 = ensureReference(ref)
|
||||
let data: Uint8Array
|
||||
try {
|
||||
@@ -113,7 +127,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef, limit
|
||||
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 = validateMetadata(data, ref.mediaType, limits)
|
||||
const metadata = 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')
|
||||
}
|
||||
|
||||
93
packages/attachment/attachment-local/tests/image.spec.ts
Normal file
93
packages/attachment/attachment-local/tests/image.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectImage } from '../src/image.ts'
|
||||
|
||||
function bytes(text: string): number[] {
|
||||
return [...Buffer.from(text, 'ascii')]
|
||||
}
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
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('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/)
|
||||
})
|
||||
})
|
||||
39
packages/attachment/attachment-local/tests/index.spec.ts
Normal file
39
packages/attachment/attachment-local/tests/index.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import LocalAttachmentStore, {
|
||||
DEFAULT_MAX_IMAGE_BYTES,
|
||||
DEFAULT_MAX_IMAGE_PIXELS,
|
||||
DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||
DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
|
||||
} from '../src/index.ts'
|
||||
|
||||
describe('local attachment service', () => {
|
||||
it('resolves every omitted admission limit explicitly', () => {
|
||||
const service = new LocalAttachmentStore(new Context(), {})
|
||||
expect(service.imageLimits).toEqual({
|
||||
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
|
||||
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||
maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
|
||||
maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
})
|
||||
})
|
||||
|
||||
it('saves and reads through the service boundary', async () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-'))
|
||||
try {
|
||||
const service = new LocalAttachmentStore(new Context(), { dshHome })
|
||||
const data = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
const ref = await service.saveImage({ data, mediaType: 'image/png' })
|
||||
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -54,11 +54,21 @@ describe('local attachment store', () => {
|
||||
expect(new Uint8Array(await readFile(object))).toEqual(PNG)
|
||||
expect((await stat(object)).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700)
|
||||
await expect(readImageFile(storageRoot, first, LIMITS)).resolves.toEqual({ ref: first, data: PNG })
|
||||
await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG })
|
||||
})
|
||||
|
||||
it('keeps admitted history readable after deployment limits become stricter', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
|
||||
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
|
||||
})
|
||||
|
||||
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
|
||||
const storageRoot = await root()
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: new Uint8Array(0), mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: Uint8Array.of(1, 2, 3), mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
@@ -74,6 +84,10 @@ describe('local attachment store', () => {
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: wide, mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
const unnamed = await saveImageFile(storageRoot, {
|
||||
data: PNG, mediaType: 'image/png', name: '\u0000',
|
||||
}, LIMITS)
|
||||
expect(unnamed).not.toHaveProperty('name')
|
||||
})
|
||||
|
||||
it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => {
|
||||
@@ -83,14 +97,45 @@ describe('local attachment store', () => {
|
||||
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await chmod(object, 0o600)
|
||||
await writeFile(object, Uint8Array.of(1, 2, 3))
|
||||
await expect(readImageFile(storageRoot, ref, LIMITS))
|
||||
await expect(readImageFile(storageRoot, ref))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
|
||||
await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }, LIMITS))
|
||||
await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }))
|
||||
.rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' })
|
||||
|
||||
const missingRoot = await root()
|
||||
await mkdir(missingRoot, { recursive: true })
|
||||
await expect(readImageFile(missingRoot, ref, LIMITS))
|
||||
await expect(readImageFile(missingRoot, ref))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' })
|
||||
|
||||
const unreadableRoot = await root()
|
||||
const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await mkdir(target, { recursive: true })
|
||||
await expect(readImageFile(unreadableRoot, ref))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' })
|
||||
})
|
||||
|
||||
it('rejects conflicting existing objects and reference metadata mismatches', async () => {
|
||||
const storageRoot = await root()
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true })
|
||||
await writeFile(target, Uint8Array.of(1, 2, 3))
|
||||
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
|
||||
|
||||
await writeFile(target, PNG)
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 }))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
|
||||
})
|
||||
|
||||
it('maps unexpected publication failures to a stable storage error', async () => {
|
||||
const storageRoot = await root()
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await mkdir(target, { recursive: true })
|
||||
|
||||
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ResponseValue,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
@@ -20,6 +21,9 @@ export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
/** Successful value returned by the connection-generation host handshake. */
|
||||
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
@@ -44,6 +44,8 @@ export type ConnectionState = 'connected' | 'reconnecting'
|
||||
export interface ConnectionSinks {
|
||||
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** Latest successful host capability snapshot for this connection generation. */
|
||||
onDescription?: (description: HostDescription) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
@@ -131,13 +133,18 @@ export class ConnectionController {
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
// (see ConnectionConfig.streamOpenTimeoutMs).
|
||||
const timeout = new AbortController()
|
||||
await Promise.all([
|
||||
const [description] = await Promise.all([
|
||||
this.api.host.describe({}),
|
||||
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
|
||||
])
|
||||
timeout.abort()
|
||||
const descriptionResult = description.result
|
||||
if (!descriptionResult.ok) {
|
||||
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
|
||||
}
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) })
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
} catch {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type {
|
||||
ToolCallView, ToolResultView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ describe('connection lifecycle', () => {
|
||||
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
const descriptions: string[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
|
||||
onDescription: description => descriptions.push(description.version),
|
||||
onConnected: () => { connected++ },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
@@ -34,6 +36,7 @@ describe('connection lifecycle', () => {
|
||||
api.pushMux(subscribedFrame())
|
||||
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
expect(descriptions).toEqual(['0-fake'])
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
@@ -83,6 +86,35 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a host.describe business error as generation failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls += 1
|
||||
if (describeCalls === 1) {
|
||||
return Promise.resolve({
|
||||
rpcId: 'bad-describe' as never,
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'internal' as const, message: 'not ready', details: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
|
||||
@@ -207,6 +207,31 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accounts for every base64 padding form and reports a missing fixture attachment', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const sessionId = created.result.value.sessionId
|
||||
const prompted = await api.sessions.prompt(req({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: ['YQ==', 'YWI=', 'YWJj'].map(data => ({
|
||||
type: 'image' as const,
|
||||
mediaType: 'image/png' as const,
|
||||
data,
|
||||
})),
|
||||
}))
|
||||
expect(prompted.result.ok).toBe(true)
|
||||
const missing = await api.sessions.attachment(req({
|
||||
sessionId,
|
||||
attachmentId: 'fixture:missing' as never,
|
||||
}))
|
||||
expect(missing.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } },
|
||||
})
|
||||
await api.sessions.cancel(req({ sessionId }))
|
||||
})
|
||||
|
||||
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry, and the latest successful host capability description), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ export function apply(ctx: Context): void {
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
|
||||
onDescription: (description) => { sessions.handleDescription(description) },
|
||||
onConnected: () => { sessions.manager.handleConnected() },
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* read-only view).
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostDescription, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
@@ -101,6 +101,7 @@ export class SessionsService {
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
private description: HostDescription | undefined
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
@@ -118,6 +119,22 @@ export class SessionsService {
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the latest successful connection-generation host description.
|
||||
* @param description - capability and deployment snapshot from `host.describe`.
|
||||
*/
|
||||
handleDescription(description: HostDescription): void {
|
||||
this.description = description
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest host capability snapshot.
|
||||
* @returns the last successful description, or undefined before connection.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined {
|
||||
return this.description
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere (the sole selection write path).
|
||||
|
||||
@@ -53,6 +53,8 @@ describe('runtime client apply', () => {
|
||||
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
|
||||
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
|
||||
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
|
||||
bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 })
|
||||
expect(sessions?.hostDescription()).toEqual({ version: '0', cwd: '/f', attachedSessions: 0 })
|
||||
bench.sinks?.onConnected?.()
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
|
||||
|
||||
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
|
||||
|
||||
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.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` 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).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -94,15 +94,21 @@ export function apply(ctx: Context): void {
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
},
|
||||
addImages: (files) => {
|
||||
const images = conversation.createDraftImages(files)
|
||||
actions.addImages(images.map(image => image.id))
|
||||
addImages: (files, current) => {
|
||||
try {
|
||||
const images = conversation.createDraftImages(files, current)
|
||||
actions.addImages(images.map(image => image.id))
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
},
|
||||
removeImage: (id) => {
|
||||
conversation.releaseDraftImage(id)
|
||||
actions.removeImage(id)
|
||||
},
|
||||
draftImages: ids => conversation.draftImages(ids),
|
||||
releaseSessionImages: (id) => { conversation.releaseSessionImages(id) },
|
||||
send: (text, images: readonly ComposerAttachment[], mode) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '' && images.length === 0) return
|
||||
@@ -140,6 +146,9 @@ export function apply(ctx: Context): void {
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
inject: (): EmptyStateInjected => ({
|
||||
createDraftImages: (files, current) => conversation.createDraftImages(files, current, true),
|
||||
releaseDraftImage: (id) => { conversation.releaseDraftImage(id) },
|
||||
releaseDraftImages: (attachments) => { conversation.releaseDraftImages(attachments) },
|
||||
startSession: opts => conversation.startSession(opts),
|
||||
}),
|
||||
}, EmptyState)
|
||||
|
||||
@@ -40,15 +40,13 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
const images = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'image' }> => block.kind === 'image')
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<ImageGallery images={images} load={loadImage} align="start" />
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MessageText key={i} text={block.text} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
case 'image': return null
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SelectionTarget, ViewEntry } from './views.ts'
|
||||
|
||||
/** Browser-owned image that has not crossed the durable host boundary. */
|
||||
export interface ComposerAttachment {
|
||||
kind: 'image'
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
@@ -37,11 +38,13 @@ export interface ConversationInjected {
|
||||
version(): number
|
||||
}
|
||||
/** Create browser previews and append their ids through the declared store action. */
|
||||
addImages(files: readonly File[]): void
|
||||
addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null
|
||||
/** Release one browser preview and remove its id through the declared store action. */
|
||||
removeImage(id: string): void
|
||||
/** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */
|
||||
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
|
||||
/** Release historical image URLs when this rendered session scope unmounts. */
|
||||
releaseSessionImages(sessionId: SessionId): void
|
||||
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
|
||||
send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
@@ -72,6 +75,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
|
||||
/** Injected share of the no-session empty-state slot. */
|
||||
export interface EmptyStateInjected {
|
||||
/** Create service-owned image previews after host-capability preflight. */
|
||||
createDraftImages(files: readonly File[], current: readonly ComposerAttachment[]): readonly ComposerAttachment[]
|
||||
/** Release one service-owned image preview. */
|
||||
releaseDraftImage(id: string): void
|
||||
/** Release all service-owned image previews held by the empty state. */
|
||||
releaseDraftImages(attachments: readonly ComposerAttachment[]): void
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: {
|
||||
cwd?: string
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { ComposerAttachment } from './contract/slots.ts'
|
||||
|
||||
/** Opaque wrapper keeps browser `File` internals outside persisted store state. */
|
||||
class BrowserDraftAttachment implements ComposerAttachment {
|
||||
readonly kind = 'image' as const
|
||||
readonly id: string
|
||||
readonly previewUrl: string
|
||||
readonly #file: File
|
||||
@@ -53,10 +54,17 @@ interface ViewsState {
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
|
||||
interface ImageUrlEntry {
|
||||
readonly sessionId: SessionId
|
||||
readonly generation: number
|
||||
readonly pending: Promise<string>
|
||||
}
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
private readonly draftAttachments = new Map<string, BrowserDraftAttachment>()
|
||||
private readonly imageUrls = new Map<string, Promise<string>>()
|
||||
private readonly imageUrls = new Map<string, ImageUrlEntry>()
|
||||
private readonly imageGenerations = new Map<SessionId, number>()
|
||||
private readonly createdImageUrls = new Set<string>()
|
||||
private readonly viewsState: ViewsState = {
|
||||
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
|
||||
@@ -73,6 +81,7 @@ export class ConversationService extends Service {
|
||||
this.createdImageUrls.clear()
|
||||
this.draftAttachments.clear()
|
||||
this.imageUrls.clear()
|
||||
this.imageGenerations.clear()
|
||||
}, 'conversation attachment URL cache')
|
||||
}
|
||||
|
||||
@@ -86,6 +95,7 @@ export class ConversationService extends Service {
|
||||
*/
|
||||
async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise<void> {
|
||||
const session = this.scopedSession('send')
|
||||
this.validateImages(images, [])
|
||||
const uploaded = await Promise.all(images.map(async file => ({
|
||||
type: 'image' as const,
|
||||
mediaType: imageMediaType(file.type),
|
||||
@@ -100,9 +110,16 @@ export class ConversationService extends Service {
|
||||
/**
|
||||
* Create runtime-only draft attachments and their object URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @param current - images already present in the same composer.
|
||||
* @param checkDefaultModel - whether to apply `host.describe`'s default-model capability, used only before a session exists.
|
||||
* @returns ordered attachment descriptors whose ids may enter the chat store.
|
||||
*/
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
createDraftImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[] = [],
|
||||
checkDefaultModel = false,
|
||||
): readonly ComposerAttachment[] {
|
||||
this.validateImages(files, current, checkDefaultModel)
|
||||
return files.map((file) => {
|
||||
const attachment = new BrowserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
@@ -154,7 +171,8 @@ export class ConversationService extends Service {
|
||||
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
|
||||
const key = `${sessionId}:${attachment.attachmentId}`
|
||||
const cached = this.imageUrls.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
if (cached !== undefined) return cached.pending
|
||||
const generation = this.imageGenerations.get(sessionId) ?? 0
|
||||
const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
@@ -163,17 +181,39 @@ export class ConversationService extends Service {
|
||||
}
|
||||
const bytes = Uint8Array.from(result.value.data)
|
||||
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
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.imageUrls.delete(key)
|
||||
if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key)
|
||||
throw error
|
||||
})
|
||||
this.imageUrls.set(key, pending)
|
||||
this.imageUrls.set(key, { sessionId, generation, pending })
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every historical image URL owned by one rendered session.
|
||||
* @param sessionId - session whose rendered image scope is ending.
|
||||
*/
|
||||
releaseSessionImages(sessionId: SessionId): void {
|
||||
this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1)
|
||||
for (const [key, entry] of this.imageUrls) {
|
||||
if (entry.sessionId !== sessionId) continue
|
||||
this.imageUrls.delete(key)
|
||||
void entry.pending.then((url) => {
|
||||
if (!this.createdImageUrls.delete(url)) return
|
||||
revokePreview(url)
|
||||
}, () => {
|
||||
// A failed or generation-invalidated load owns no cached object URL.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
|
||||
async cancel(): Promise<void> {
|
||||
const session = this.scopedSession('cancel')
|
||||
@@ -284,6 +324,38 @@ export class ConversationService extends Service {
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
|
||||
/** Apply host-advertised fast-path checks before any object URL or base64 allocation. */
|
||||
private validateImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[],
|
||||
checkDefaultModel = false,
|
||||
): void {
|
||||
const description = this.requireSessions().hostDescription()
|
||||
const modalities = description?.activeModel?.inputModalities
|
||||
if (checkDefaultModel && modalities !== undefined && !modalities.includes('image')) {
|
||||
throw new Error('当前模型不支持图片输入')
|
||||
}
|
||||
const limits = description?.imageLimits
|
||||
const all = [...current.map(attachment => attachment.file), ...files]
|
||||
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
|
||||
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
|
||||
}
|
||||
let totalBytes = 0
|
||||
for (const file of all) {
|
||||
const mediaType = imageMediaType(file.type)
|
||||
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
|
||||
throw new Error(`当前部署不支持 ${mediaType} 图片`)
|
||||
}
|
||||
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
||||
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
||||
}
|
||||
totalBytes += file.size
|
||||
}
|
||||
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new Error('图片总大小超过单条消息限制')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bumpViews(state: ViewsState): void {
|
||||
|
||||
@@ -36,7 +36,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open,
|
||||
views, addImages, removeImage, draftImages, releaseSessionImages,
|
||||
send, stop, openDetails, loadOlder, open,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
@@ -63,6 +64,10 @@ export function ConversationRoot({
|
||||
}
|
||||
}, [actions, attachments, imageIds])
|
||||
|
||||
useEffect(() => () => {
|
||||
releaseSessionImages(sessionId)
|
||||
}, [releaseSessionImages, sessionId])
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
@@ -145,7 +150,7 @@ export function ConversationRoot({
|
||||
error={error}
|
||||
variant="composer"
|
||||
onDraftChange={actions.setDraft}
|
||||
onAddImages={addImages}
|
||||
onAddImages={files => addImages(files, attachments)}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={(mode) => { send(draft, attachments, mode) }}
|
||||
onStop={stop}
|
||||
|
||||
@@ -30,7 +30,13 @@ function deriveCwds(state: SessionListState): readonly string[] {
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
export function EmptyState({
|
||||
useSessions,
|
||||
createDraftImages,
|
||||
releaseDraftImage,
|
||||
releaseDraftImages,
|
||||
startSession,
|
||||
}: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
@@ -67,21 +73,22 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl)
|
||||
}, [])
|
||||
releaseDraftImages(attachmentsRef.current)
|
||||
}, [releaseDraftImages])
|
||||
|
||||
const addImages = (files: readonly File[]): void => {
|
||||
setAttachments(current => [...current, ...files.map(file => ({
|
||||
id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file),
|
||||
}))])
|
||||
const addImages = (files: readonly File[]): string | null => {
|
||||
try {
|
||||
const added = createDraftImages(files, attachments)
|
||||
setAttachments(current => [...current, ...added])
|
||||
return null
|
||||
} catch (reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : String(reason)
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (id: string): void => {
|
||||
setAttachments((current) => {
|
||||
const removed = current.find(item => item.id === id)
|
||||
if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl)
|
||||
return current.filter(item => item.id !== id)
|
||||
})
|
||||
releaseDraftImage(id)
|
||||
setAttachments(current => current.filter(item => item.id !== id))
|
||||
}
|
||||
|
||||
const picker = (
|
||||
|
||||
@@ -12,12 +12,6 @@ import type { ComposerAttachment } from '../contract/slots.ts'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
||||
|
||||
function supportedImages(files: Iterable<File>): File[] {
|
||||
return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type))
|
||||
}
|
||||
|
||||
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
@@ -36,7 +30,7 @@ export interface InputBarProps {
|
||||
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onAddImages?: (files: readonly File[]) => void
|
||||
onAddImages?: (files: readonly File[]) => string | null
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
@@ -44,7 +38,7 @@ export interface InputBarProps {
|
||||
|
||||
export function InputBar({
|
||||
draft, attachments = [], running, disabled, error, variant, placeholder, accessory,
|
||||
onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop,
|
||||
onDraftChange, onAddImages = () => null, onRemoveAttachment = () => {}, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === '' && attachments.length === 0
|
||||
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
|
||||
@@ -90,13 +84,12 @@ export function InputBar({
|
||||
|
||||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => {
|
||||
const files = [...event.clipboardData.items]
|
||||
.filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type))
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length === 0) return
|
||||
event.preventDefault()
|
||||
setDropError(null)
|
||||
onAddImages(files)
|
||||
if (event.clipboardData.getData('text/plain') === '') event.preventDefault()
|
||||
setDropError(onAddImages(files))
|
||||
}
|
||||
|
||||
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
|
||||
@@ -127,13 +120,8 @@ export function InputBar({
|
||||
setDragActive(false)
|
||||
if (locked) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
const images = supportedImages(dropped)
|
||||
if (images.length === 0) {
|
||||
setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片')
|
||||
return
|
||||
}
|
||||
setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件')
|
||||
onAddImages(images)
|
||||
if (dropped.length === 0) return
|
||||
setDropError(onAddImages(dropped))
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
@@ -187,7 +175,10 @@ export function InputBar({
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={`移除图片 ${attachment.file.name || ''}`}
|
||||
onClick={() => { onRemoveAttachment(attachment.id) }}
|
||||
onClick={() => {
|
||||
setDropError(null)
|
||||
onRemoveAttachment(attachment.id)
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -204,7 +195,10 @@ export function InputBar({
|
||||
disabled={locked}
|
||||
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDropError(null)
|
||||
onDraftChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
|
||||
@@ -74,6 +74,7 @@ async function bench() {
|
||||
manager: { get: () => sessionFake },
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
hostDescription: () => undefined,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
@@ -200,12 +201,17 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
|
||||
it('empty injects draft-image lifecycle and the startSession chain without a store', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession'])
|
||||
expect(Object.keys(injected)).toEqual([
|
||||
'createDraftImages',
|
||||
'releaseDraftImage',
|
||||
'releaseDraftImages',
|
||||
'startSession',
|
||||
])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
|
||||
@@ -132,8 +132,9 @@ describe('error strip and variants', () => {
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const { textarea } = setup({ draft: '', onAddImages })
|
||||
const onAddImages = vi.fn((files: readonly File[]) =>
|
||||
files.some(file => file.type === 'video/mp4') ? '不支持的图片格式:video/mp4' : null)
|
||||
const { view, textarea } = setup({ draft: '', onAddImages })
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
const prevented = fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
@@ -141,19 +142,25 @@ describe('image draft rail', () => {
|
||||
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => image },
|
||||
],
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(prevented).toBe(false)
|
||||
expect(prevented).toBe(true)
|
||||
expect(onAddImages).toHaveBeenCalledWith([image])
|
||||
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] },
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
expect(onAddImages).toHaveBeenCalledTimes(1)
|
||||
expect(onAddImages).toHaveBeenCalledTimes(2)
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const onAddImages = vi.fn(() => null)
|
||||
const { view } = setup({ draft: '', onAddImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' })
|
||||
@@ -172,15 +179,16 @@ describe('image draft rail', () => {
|
||||
})
|
||||
|
||||
it('ignores unsupported dropped files and refuses drops while locked', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const onAddImages = vi.fn((files: readonly File[]) =>
|
||||
files.some(file => file.type === 'text/plain') ? '不支持的图片格式:text/plain' : null)
|
||||
const { view } = setup({ draft: '', onAddImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' })
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
|
||||
})
|
||||
expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy()
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
expect(onAddImages).toHaveBeenCalledWith([documentFile])
|
||||
|
||||
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
|
||||
const locked = setup({ draft: '', disabled: true, onAddImages })
|
||||
@@ -191,12 +199,12 @@ describe('image draft rail', () => {
|
||||
fireEvent.dragOver(lockedCard, { dataTransfer })
|
||||
expect(dataTransfer.dropEffect).toBe('none')
|
||||
fireEvent.drop(lockedCard, { dataTransfer })
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
expect(onAddImages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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 = { id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const onRemoveAttachment = vi.fn()
|
||||
const { view, textarea, props } = setup({
|
||||
draft: '', attachments: [attachment], onRemoveAttachment,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -41,4 +42,23 @@ describe('MessageImage', () => {
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment, alt: 'middle' },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('middle')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -32,9 +34,17 @@ const SCOPE_TAG: symbol = (() => {
|
||||
interface SessionDouble {
|
||||
prompt: ReturnType<typeof vi.fn>
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
readAttachment: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function bench(opts?: { sessions?: boolean }) {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function bench(opts?: {
|
||||
sessions?: boolean
|
||||
description?: ReturnType<SessionsService['hostDescription']>
|
||||
}) {
|
||||
const ctx = new Context()
|
||||
const sessionDoubles = new Map<SessionId, SessionDouble>()
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
@@ -57,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
|
||||
s = {
|
||||
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
|
||||
}
|
||||
sessionDoubles.set(id, s)
|
||||
}
|
||||
@@ -66,6 +77,7 @@ async function bench(opts?: { sessions?: boolean }) {
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
hostDescription: () => opts?.description,
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
|
||||
@@ -133,6 +145,127 @@ describe('send / cancel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('image admission and URL lifecycle', () => {
|
||||
const description: NonNullable<ReturnType<SessionsService['hostDescription']>> = {
|
||||
version: '0',
|
||||
cwd: '/f',
|
||||
attachedSessions: 0,
|
||||
activeModel: {
|
||||
provider: 'anthropic',
|
||||
id: 'claude-opus-4-8',
|
||||
name: 'Opus',
|
||||
inputModalities: ['text', 'image'],
|
||||
outputModalities: ['text'],
|
||||
},
|
||||
imageLimits: {
|
||||
maxImageBytes: 3,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 4,
|
||||
maxImagePixels: 100,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
}
|
||||
|
||||
it('preflights host limits before allocating previews and releases draft URLs', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:draft')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench({ description })
|
||||
const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' })
|
||||
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
|
||||
|
||||
const attachments = b.svc.createDraftImages([first])
|
||||
expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' })
|
||||
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseDraftImages(attachments)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft')
|
||||
})
|
||||
|
||||
it('rejects unsupported model capability, media type, count, and per-image bytes', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:unexpected')
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() })
|
||||
const textOnly = await bench({
|
||||
description: {
|
||||
...description,
|
||||
activeModel: { ...description.activeModel!, inputModalities: ['text'] },
|
||||
},
|
||||
})
|
||||
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/)
|
||||
|
||||
const b = await bench({ description })
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
|
||||
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' })
|
||||
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
|
||||
const existing = b.svc.createDraftImages([png, png])
|
||||
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => {
|
||||
const createObjectURL = vi.fn()
|
||||
.mockReturnValueOnce('blob:history-1')
|
||||
.mockReturnValueOnce('blob:history-2')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench()
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
const session = b.sessionDoubles.get(sid('s1'))!
|
||||
session.readAttachment.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { attachment: ref, data: [1] },
|
||||
})
|
||||
|
||||
await expect(Promise.all([
|
||||
b.svc.resolveImage(sid('s1'), ref),
|
||||
b.svc.resolveImage(sid('s1'), ref),
|
||||
])).resolves.toEqual(['blob:history-1', 'blob:history-1'])
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(1)
|
||||
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') })
|
||||
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
|
||||
expect(session.readAttachment).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('revokes a historical URL whose load completes after its session scope was released', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:late')
|
||||
const revokeObjectURL = vi.fn()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
const b = await bench()
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const response = Promise.withResolvers<{
|
||||
ok: true
|
||||
value: { attachment: ImageAttachmentRef; data: number[] }
|
||||
}>()
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise)
|
||||
|
||||
const pending = b.svc.resolveImage(sid('s1'), ref)
|
||||
b.svc.releaseSessionImages(sid('s1'))
|
||||
response.resolve({ ok: true, value: { attachment: ref, data: [1] } })
|
||||
|
||||
await expect(pending).rejects.toThrow(/scope was released/)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSession chain', () => {
|
||||
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
|
||||
const b = await bench()
|
||||
|
||||
@@ -71,9 +71,10 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn()}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
@@ -132,9 +133,10 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn()}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
@@ -240,7 +242,13 @@ describe('EmptyState branches', () => {
|
||||
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
|
||||
const view = render(
|
||||
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
|
||||
<EmptyState
|
||||
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'first task' } })
|
||||
@@ -252,7 +260,13 @@ describe('EmptyState branches', () => {
|
||||
it('non-Error rejection reasons stringify into the error strip', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject('plain-string'))
|
||||
const view = render(
|
||||
<EmptyState useSessions={listHook([])} startSession={startSession} />,
|
||||
<EmptyState
|
||||
useSessions={listHook([])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'go' } })
|
||||
@@ -268,6 +282,9 @@ describe('EmptyState branches', () => {
|
||||
{ id: 'a', title: 'a', cwd: '/proj' },
|
||||
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
||||
])}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -68,7 +68,15 @@ describe('EmptyState', () => {
|
||||
])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
|
||||
const select = screen.getByRole('combobox', { name: '项目目录' })
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
@@ -87,12 +95,59 @@ describe('EmptyState', () => {
|
||||
|
||||
it('new-directory option swaps the select for a free-form input', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={() => []}
|
||||
releaseDraftImage={() => {}}
|
||||
releaseDraftImages={() => {}}
|
||||
startSession={() => Promise.resolve()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
|
||||
const custom = screen.getByPlaceholderText(/目录路径/)
|
||||
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
|
||||
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
|
||||
})
|
||||
|
||||
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
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 createDraftImages = vi.fn()
|
||||
.mockReturnValueOnce([attachment])
|
||||
.mockImplementationOnce(() => { throw new Error('图片过大') })
|
||||
const releaseDraftImage = vi.fn()
|
||||
const releaseDraftImages = vi.fn()
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
createDraftImages={createDraftImages}
|
||||
releaseDraftImage={releaseDraftImage}
|
||||
releaseDraftImages={releaseDraftImages}
|
||||
startSession={() => Promise.resolve()}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const clipboardData = {
|
||||
items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
|
||||
getData: () => '',
|
||||
}
|
||||
fireEvent.paste(textarea, { clipboardData })
|
||||
expect(createDraftImages).toHaveBeenCalledWith([file], [])
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(releaseDraftImage).toHaveBeenCalledWith('draft-1')
|
||||
|
||||
fireEvent.paste(textarea, { clipboardData })
|
||||
expect(view.getByText('图片过大')).toBeTruthy()
|
||||
view.unmount()
|
||||
expect(releaseDraftImages).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
@@ -121,9 +176,10 @@ describe('ConversationRoot', () => {
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
addImages={vi.fn()}
|
||||
addImages={vi.fn(() => null)}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={send}
|
||||
stop={stop}
|
||||
openDetails={openDetails}
|
||||
|
||||
@@ -99,6 +99,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
|
||||
addImages={vi.fn()}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
releaseSessionImages={vi.fn()}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
|
||||
@@ -13,7 +13,7 @@ This backend owns the compaction policy:
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
@@ -100,7 +100,7 @@ Replacing rather than append-only. Each checkpoint invalidates reuse from the fi
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages, including image references, that the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored.
|
||||
|
||||
##### Compaction instruction (final user message)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function summarizeWithLlm(
|
||||
const error = finishError(assembler.finish)
|
||||
if (error !== undefined) throw error
|
||||
|
||||
const summary = textOnly(assembler.message().content)
|
||||
const summary = summaryText(assembler.message().content)
|
||||
if (!summary.some(block => block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
@@ -189,9 +189,18 @@ function finishError(finish: FinishReason): Error | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep only text blocks before synthesizing a user message. */
|
||||
function textOnly(
|
||||
/** Reject visual output and keep only text before synthesizing a user message. */
|
||||
function summaryText(
|
||||
blocks: readonly ContentBlock[],
|
||||
): Array<Extract<ContentBlock, { type: 'text' }>> {
|
||||
if (containsImage(blocks)) {
|
||||
throw new LlmError('compaction summary cannot contain image output', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
|
||||
/** Detect images recursively so no structured result can hide a silent visual drop. */
|
||||
function containsImage(blocks: readonly ContentBlock[]): boolean {
|
||||
return blocks.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && containsImage(block.content)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||
@@ -1103,7 +1104,22 @@ describe('default one-shot summarizer', () => {
|
||||
it('replays the conversation prefix and appends the instruction as the final message', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
||||
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
||||
const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] }
|
||||
const prefix: Message = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'earlier turn' },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
await compact.runSummarize({
|
||||
system: 'REPLAYED SYSTEM',
|
||||
tools,
|
||||
@@ -1256,6 +1272,43 @@ describe('default one-shot summarizer', () => {
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
|
||||
.rejects.toThrow(/no text summary content/)
|
||||
})
|
||||
|
||||
it('rejects image summary output instead of silently dropping it', async () => {
|
||||
const { compact } = await summarizerHarness([
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: 'partial summary' },
|
||||
])
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
})
|
||||
|
||||
it('rejects image summary output nested in a tool result', async () => {
|
||||
const { compact } = await summarizerHarness([{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId('summary-tool'),
|
||||
content: [{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
}],
|
||||
}])
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('automatic listener and loader composition', () => {
|
||||
|
||||
@@ -387,11 +387,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
if (content.some(part => part.type === 'image')) {
|
||||
const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model)
|
||||
const routed = agent.session.requestHeader()?.config
|
||||
const provider = routed?.provider ?? agent.options.provider ?? defaults.provider
|
||||
const model = routed?.model ?? agent.options.model ?? defaults.model
|
||||
const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model)
|
||||
if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: `Model "${defaults.model}" does not support image input.`,
|
||||
message: `Model "${model}" does not support image input.`,
|
||||
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | 'hang')[],
|
||||
private readonly inputModalities: readonly ModelModality[] = ['text', 'image'],
|
||||
private readonly model = 'test-model',
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve([{
|
||||
provider, id: 'test-model', name: 'test-model',
|
||||
provider, id: this.model, name: this.model,
|
||||
inputModalities: this.inputModalities, outputModalities: ['text'],
|
||||
}])
|
||||
}
|
||||
@@ -112,11 +113,38 @@ describe('bootHost / startHost', () => {
|
||||
const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
|
||||
const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
|
||||
expect(parsed.result.value.provider).toBe('scripted')
|
||||
const attachmentBody = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-attachment',
|
||||
method: 'session.attachment',
|
||||
payload: { sessionId: 'session-missing', attachmentId: 'sha256:missing' },
|
||||
})
|
||||
const attachmentResponse = await running.handler.fetch(new Request('http://x/api/session.attachment', {
|
||||
method: 'POST',
|
||||
body: attachmentBody,
|
||||
}))
|
||||
expect((await attachmentResponse.json() as { result: { ok: boolean } }).result.ok).toBe(false)
|
||||
const first = running.dispose()
|
||||
expect(running.dispose()).toBe(first)
|
||||
await first
|
||||
host = undefined
|
||||
})
|
||||
|
||||
it('mounts configured pi-ai providers while accepting an explicit empty list', async () => {
|
||||
const empty = await bootHost({
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-empty-')),
|
||||
piAiProviders: [],
|
||||
})
|
||||
expect(empty.ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await empty.dispose()
|
||||
|
||||
const configured = await bootHost({
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-')),
|
||||
piAiProviders: [{ provider: 'openai' }],
|
||||
})
|
||||
expect(configured.ctx.llm.listProviders()).toContainEqual({ id: 'openai', name: 'openai' })
|
||||
await configured.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host.describe', () => {
|
||||
@@ -125,6 +153,18 @@ describe('host.describe', () => {
|
||||
const value = expectOk(await api.host.describe(request({})))
|
||||
expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
|
||||
})
|
||||
|
||||
it('omits activeModel when the configured model is absent from the provider catalog', async () => {
|
||||
host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-describe-missing-model-')),
|
||||
provider: 'scripted',
|
||||
model: 'missing-model',
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'], 'other-model'))
|
||||
expect(expectOk(await host.api.host.describe(request({})))).not.toHaveProperty('activeModel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.create / list', () => {
|
||||
@@ -239,6 +279,125 @@ describe('sessions.prompt / cancel', () => {
|
||||
expect(denied.result).toMatchObject({
|
||||
ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
|
||||
})
|
||||
|
||||
const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({})))
|
||||
const nestedAgent = host.ctx.agents.get(nestedSession) as Agent
|
||||
nestedAgent.session.append('context/message', {
|
||||
content: [
|
||||
null,
|
||||
[],
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'nested-text' as never,
|
||||
content: [{ type: 'text', text: 'no image here' }],
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'nested-image' as never,
|
||||
content: [{ type: 'image', attachment: image.attachment }],
|
||||
},
|
||||
] as never,
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expectOk(await host.api.sessions.attachment(request({
|
||||
sessionId: nestedSession,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
})))
|
||||
|
||||
const { sessionId: streamedSession } = expectOk(await host.api.sessions.create(request({})))
|
||||
const streamedAgent = host.ctx.agents.get(streamedSession) as Agent
|
||||
streamedAgent.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: image.attachment } },
|
||||
})
|
||||
expectOk(await host.api.sessions.attachment(request({
|
||||
sessionId: streamedSession,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
})))
|
||||
|
||||
const missingRef = {
|
||||
...image.attachment,
|
||||
attachmentId: `sha256:${'b'.repeat(64)}` as never,
|
||||
}
|
||||
streamedAgent.session.append('context/message', {
|
||||
content: [{ type: 'image', attachment: missingRef }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const missing = await host.api.sessions.attachment(request({
|
||||
sessionId: streamedSession,
|
||||
attachmentId: missingRef.attachmentId,
|
||||
}))
|
||||
expect(missing.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } },
|
||||
})
|
||||
|
||||
const read = vi.spyOn(host.ctx.attachments, 'readImage').mockRejectedValueOnce(new Error('read failed'))
|
||||
const internal = await host.api.sessions.attachment(request({
|
||||
sessionId: nestedSession,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
}))
|
||||
expect(internal.result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
read.mockRestore()
|
||||
|
||||
const ghost = await host.api.sessions.attachment(request({
|
||||
sessionId: 'session-ghost' as SessionId,
|
||||
attachmentId: image.attachment.attachmentId,
|
||||
}))
|
||||
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('rejects non-canonical, excessive-count, and excessive-byte image prompts', async () => {
|
||||
const running = await boot()
|
||||
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||
for (const data of ['', 'AB==']) {
|
||||
const invalid = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data }],
|
||||
}))
|
||||
expect(invalid.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'INVALID_IMAGE_BASE64' } },
|
||||
})
|
||||
}
|
||||
|
||||
const attachmentService = running.ctx.attachments as unknown as {
|
||||
imageLimits: typeof running.ctx.attachments.imageLimits
|
||||
}
|
||||
attachmentService.imageLimits = {
|
||||
...running.ctx.attachments.imageLimits,
|
||||
maxImagesPerMessage: 1,
|
||||
}
|
||||
const tooMany = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: Array.from({ length: 2 }, () => ({
|
||||
type: 'image' as const,
|
||||
mediaType: 'image/png' as const,
|
||||
data: PNG_BASE64,
|
||||
})),
|
||||
}))
|
||||
expect(tooMany.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'TOO_MANY_IMAGES' } },
|
||||
})
|
||||
|
||||
attachmentService.imageLimits = {
|
||||
...running.ctx.attachments.imageLimits,
|
||||
maxImagesPerMessage: 10,
|
||||
maxMessageImageBytes: 100,
|
||||
}
|
||||
const excessiveBytes = await running.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: Array.from({ length: 2 }, () => ({
|
||||
type: 'image' as const,
|
||||
mediaType: 'image/png' as const,
|
||||
data: PNG_BASE64,
|
||||
})),
|
||||
}))
|
||||
expect(excessiveBytes.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'IMAGES_TOO_LARGE' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects images for an explicitly text-only model without creating a session event', async () => {
|
||||
@@ -260,6 +419,57 @@ describe('sessions.prompt / cancel', () => {
|
||||
expect(existsSync(join(dshHome, 'attachments'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preflights the session route instead of the host default model', async () => {
|
||||
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-routed-session-'))
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-routed-home-'))
|
||||
host = await startHost({
|
||||
boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' },
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
|
||||
host.ctx.llm.registerAdapter(
|
||||
['visual'],
|
||||
new ScriptedAdapter([textResponse('seen')], ['text', 'image'], 'visual-model'),
|
||||
)
|
||||
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
|
||||
const agent = host.ctx.agents.get(sessionId) as Agent
|
||||
agent.options.provider = 'visual'
|
||||
agent.options.model = 'visual-model'
|
||||
const idle = waitForIdle(host.ctx, agent)
|
||||
|
||||
expectOk(await host.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
|
||||
expect(existsSync(join(dshHome, 'attachments'))).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to host routing when a session has no routed or agent model options', async () => {
|
||||
host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-route-default-')),
|
||||
provider: 'scripted',
|
||||
model: 'test-model',
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
|
||||
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
|
||||
const agent = host.ctx.agents.get(sessionId) as Agent
|
||||
agent.options.provider = undefined as never
|
||||
agent.options.model = undefined as never
|
||||
const response = await host.api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false, error: { details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels an attached agent and rejects an unattached one', async () => {
|
||||
const running = await boot(['hang'])
|
||||
const { api, ctx } = running
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
|
||||
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply the bind `host`, `port`, and positive `maxRequestBodyBytes`; port `0` requests an OS-assigned port and the running handle reports the assigned value. The API bridge returns 413 before buffering a declared oversized body and keeps chunked-body buffering within the same cap. `dsh web` derives its default cap from the configured aggregate image limit plus base64/envelope expansion and accepts `--max-request-body-bytes` as an explicit override. It defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface WebServerOptions {
|
||||
distIndex: string
|
||||
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
|
||||
apiHandler: { fetch: typeof fetch }
|
||||
/** Maximum buffered bytes accepted for one `/api/*` request body. */
|
||||
maxRequestBodyBytes: number
|
||||
/**
|
||||
* Web plugin table. When present, every index.html response carries a
|
||||
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
|
||||
@@ -66,7 +68,10 @@ export interface RunningWebServer {
|
||||
* @returns the running server handle once listening.
|
||||
*/
|
||||
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
|
||||
const { host, port, distIndex, apiHandler, webPlugins } = options
|
||||
const { host, port, distIndex, apiHandler, maxRequestBodyBytes, webPlugins } = options
|
||||
if (!Number.isInteger(maxRequestBodyBytes) || maxRequestBodyBytes < 1) {
|
||||
throw new RangeError('host webserver: maxRequestBodyBytes must be a positive integer')
|
||||
}
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
|
||||
const html = await readFile(distIndex, 'utf8')
|
||||
@@ -78,7 +83,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
|
||||
requests; the field is only optional on the client-side IncomingMessage type */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
if (rawPath.startsWith('/api/')) {
|
||||
await bridge(req, res, apiHandler)
|
||||
await bridge(req, res, apiHandler, maxRequestBodyBytes)
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
@@ -163,8 +168,13 @@ async function servePluginBundle(
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
|
||||
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
/** Bridge one bounded node:http request to the WHATWG fetch handler. */
|
||||
async function bridge(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
apiHandler: { fetch: typeof fetch },
|
||||
maxRequestBodyBytes: number,
|
||||
): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
@@ -174,8 +184,31 @@ async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { f
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) abort.abort()
|
||||
})
|
||||
const declaredLength = req.headers['content-length']
|
||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
||||
res.writeHead(413)
|
||||
res.end()
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
let received = 0
|
||||
let oversized = false
|
||||
for await (const chunk of req) {
|
||||
const buffer = chunk as Buffer
|
||||
received += buffer.byteLength
|
||||
if (received > maxRequestBodyBytes) {
|
||||
oversized = true
|
||||
chunks.length = 0
|
||||
continue
|
||||
}
|
||||
if (!oversized) chunks.push(buffer)
|
||||
}
|
||||
if (oversized) {
|
||||
res.writeHead(413)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
||||
requests; the fields are only optional on the client-side IncomingMessage type */
|
||||
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startWebServer, type RunningWebServer } from '../src/index.ts'
|
||||
|
||||
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
||||
|
||||
/** Reserve a loopback port for tests that need to address a second server. */
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -104,17 +107,35 @@ afterEach(async () => {
|
||||
server = undefined
|
||||
})
|
||||
|
||||
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
|
||||
async function boot(
|
||||
onError: (err: Error) => void = () => undefined,
|
||||
maxRequestBodyBytes = MAX_REQUEST_BODY_BYTES,
|
||||
): Promise<string> {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes,
|
||||
}, onError)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
describe('startWebServer', () => {
|
||||
it('rejects an invalid request-body cap before listening', () => {
|
||||
const { distIndex } = makeDist()
|
||||
expect(() => startWebServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
distIndex,
|
||||
apiHandler: echoingApi,
|
||||
maxRequestBodyBytes: 0,
|
||||
}, () => undefined)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
it('reports the listening port and closes idempotently', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined)
|
||||
expect(server.port).toBeGreaterThan(0)
|
||||
const first = server.close()
|
||||
const second = server.close()
|
||||
@@ -136,7 +157,9 @@ describe('startWebServer', () => {
|
||||
})
|
||||
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
|
||||
try {
|
||||
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
const inertServer = await startWebServer({
|
||||
host, port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined)
|
||||
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
|
||||
await inertServer.close()
|
||||
} finally {
|
||||
@@ -148,8 +171,12 @@ describe('startWebServer', () => {
|
||||
it('rejects when the port is already taken', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
|
||||
server = await startWebServer({
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined)
|
||||
await expect(startWebServer({
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||
}, () => undefined))
|
||||
.rejects.toMatchObject({ code: 'EADDRINUSE' })
|
||||
})
|
||||
})
|
||||
@@ -207,7 +234,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
}
|
||||
const port = await freePort()
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
{
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi,
|
||||
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins,
|
||||
}, () => undefined,
|
||||
)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
@@ -245,7 +275,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
}
|
||||
const port = await freePort()
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
{
|
||||
host: '127.0.0.1', port, distIndex, apiHandler: echoingApi,
|
||||
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins,
|
||||
}, () => undefined,
|
||||
)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -305,6 +338,35 @@ describe('/api bridge', () => {
|
||||
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
|
||||
})
|
||||
|
||||
it('returns 413 before buffering a declared oversized body', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/echo`, {
|
||||
method: 'POST',
|
||||
body: 'x'.repeat(MAX_REQUEST_BODY_BYTES + 1),
|
||||
})
|
||||
expect(response.status).toBe(413)
|
||||
})
|
||||
|
||||
it('bounds chunked request buffering when no content length is declared', async () => {
|
||||
const base = await boot(() => undefined, 8)
|
||||
const target = new URL(`${base}/api/echo`)
|
||||
const status = await new Promise<number | undefined>((resolve, reject) => {
|
||||
const request = httpRequest({
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: target.pathname,
|
||||
method: 'POST',
|
||||
}, (response) => {
|
||||
response.resume()
|
||||
response.on('end', () => { resolve(response.statusCode) })
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.write('12345')
|
||||
request.end('67890')
|
||||
})
|
||||
expect(status).toBe(413)
|
||||
})
|
||||
|
||||
it('relays a bodyless response', async () => {
|
||||
const base = await boot()
|
||||
const response = await fetch(`${base}/api/empty`, { method: 'POST' })
|
||||
|
||||
@@ -34,6 +34,8 @@ 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.
|
||||
|
||||
## Provider/model routing and replay
|
||||
|
||||
The selected pi-ai catalog descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
|
||||
|
||||
@@ -25,8 +25,8 @@ import { toStreamChunks } from './stream.ts'
|
||||
export interface PiAiAdapterOptions {
|
||||
/** Validated provider profiles this adapter instance owns. */
|
||||
profiles: readonly PiAiProviderProfile[]
|
||||
/** Durable image resolver used only when a request contains image references. */
|
||||
attachments?: AttachmentStore
|
||||
/** Resolve durable image storage at request time so plugin load order does not become capability state. */
|
||||
resolveAttachments?: () => AttachmentStore | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,12 +72,12 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
|
||||
*/
|
||||
export class PiAiAdapter extends LlmAdapter {
|
||||
private readonly profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
|
||||
private readonly attachments: AttachmentStore | undefined
|
||||
private readonly resolveAttachments: () => AttachmentStore | undefined
|
||||
|
||||
constructor(options: PiAiAdapterOptions) {
|
||||
super()
|
||||
this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
|
||||
this.attachments = options.attachments
|
||||
this.resolveAttachments = options.resolveAttachments ?? (() => undefined)
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
@@ -136,12 +136,13 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
if (containsImage && !model.input.includes('image')) {
|
||||
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
if (containsImage && this.attachments === undefined) {
|
||||
const attachments = containsImage ? this.resolveAttachments() : undefined
|
||||
if (containsImage && attachments === undefined) {
|
||||
throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
const context = this.attachments === undefined
|
||||
const context = attachments === undefined
|
||||
? toPiContext(options)
|
||||
: await toPiContext(options, this.attachments)
|
||||
: await toPiContext(options, attachments)
|
||||
const events = streamSimple(model, context, {
|
||||
...profileOptions(profile),
|
||||
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
||||
|
||||
@@ -36,10 +36,9 @@ export const inject = ['llm']
|
||||
/** Register one generic pi-ai adapter for all configured provider routes. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const profiles = resolveProfiles(config.providers)
|
||||
const attachments = ctx.get('attachments')
|
||||
const adapter = new PiAiAdapter({
|
||||
profiles,
|
||||
...(attachments === undefined ? {} : { attachments }),
|
||||
resolveAttachments: () => ctx.get('attachments'),
|
||||
})
|
||||
ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
@@ -88,6 +95,14 @@ const textEvents = [
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
const IMAGE_REF: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
|
||||
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -189,6 +204,55 @@ describe('PiAiAdapter provider routing', () => {
|
||||
expect(server.paths).toEqual(['/v1/responses'])
|
||||
})
|
||||
|
||||
it('resolves an attachment service mounted after the adapter when dispatching an image', async () => {
|
||||
const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }])
|
||||
const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`)
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const readImage = vi.fn((_ref: ImageAttachmentRef): Promise<StoredImageAttachment> =>
|
||||
Promise.resolve({ ref, data: Uint8Array.of(1) }))
|
||||
|
||||
class LateAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits: ImageAttachmentLimits = {
|
||||
maxImageBytes: 1,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
mediaTypes: ['image/png'],
|
||||
}
|
||||
|
||||
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
|
||||
readImage(value: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return readImage(value)
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
|
||||
})
|
||||
await ctx.plugin(LateAttachmentStore)
|
||||
|
||||
const result = await assemble(ctx, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }],
|
||||
})
|
||||
|
||||
expect(result.finish.kind).toBe('error')
|
||||
expect(readImage).toHaveBeenCalledWith(ref)
|
||||
expect(server.paths).toEqual(['/v1/responses'])
|
||||
})
|
||||
|
||||
it('forces one wire request for an SDK-retryable provider failure', async () => {
|
||||
const server = await mockServer([
|
||||
{
|
||||
@@ -387,6 +451,38 @@ describe('provider profile lifecycle', () => {
|
||||
expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('rejects unsupported or unresolved image input before provider I/O', async () => {
|
||||
const adapter = new PiAiAdapter({
|
||||
profiles: [{ provider: 'openai' }, { provider: 'deepseek' }],
|
||||
})
|
||||
const drain = async (options: Parameters<PiAiAdapter['stream']>[0]): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream(options)) { /* drain */ }
|
||||
}
|
||||
|
||||
await expect(drain({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'image', attachment: IMAGE_REF }] }],
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
await expect(drain({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
messages: [{ role: 'user', content: [{ type: 'image', attachment: IMAGE_REF }] }],
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
await expect(drain({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-image' as never,
|
||||
content: [{ type: 'image', attachment: IMAGE_REF }],
|
||||
}],
|
||||
}],
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
})
|
||||
|
||||
it('validates direct-constructor profiles at the embedding boundary', () => {
|
||||
expect(() => new PiAiAdapter({
|
||||
profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }],
|
||||
|
||||
207
packages/llm/llm-pi-ai/tests/context.spec.ts
Normal file
207
packages/llm/llm-pi-ai/tests/context.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import { toPiContext } from '../src/context.ts'
|
||||
import { toPiAssistant } from '../src/replay.ts'
|
||||
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
|
||||
const attachments = {
|
||||
readImage: vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1) })),
|
||||
} as unknown as AttachmentStore
|
||||
|
||||
function request(messages: GenerateOptions['messages']): GenerateOptions {
|
||||
return {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
system: 'system prompt',
|
||||
tools: [{ name: 'lookup', description: 'look up', parameters: { type: 'object' } }],
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
describe('pi-ai request context conversion', () => {
|
||||
it('omits absent and empty request-level optional fields', () => {
|
||||
const base = { provider: 'openai', model: 'gpt-4.1', messages: [] }
|
||||
expect(toPiContext(base)).toEqual({ messages: [] })
|
||||
expect(toPiContext({ ...base, tools: [] })).toEqual({ messages: [] })
|
||||
})
|
||||
|
||||
it('converts complete text-only history and rejects nested images without storage', () => {
|
||||
const callId = CallId('call-1')
|
||||
expect(toPiContext(request([
|
||||
{ role: 'system', content: [{ type: 'text', text: 'history system' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: callId, name: 'lookup', arguments: '{}' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'after tool' },
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]))).toMatchObject({
|
||||
systemPrompt: 'system prompt',
|
||||
tools: [{ name: 'lookup' }],
|
||||
messages: [
|
||||
{ role: 'user', content: 'history system' },
|
||||
{ role: 'assistant' },
|
||||
{ role: 'user', content: 'after tool' },
|
||||
{
|
||||
role: 'toolResult',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'lookup',
|
||||
content: [{ type: 'text', text: '(no output)' }],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(() => toPiContext(request([{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'image', attachment: ref }],
|
||||
}],
|
||||
}]))).toThrow(/durable attachment service/)
|
||||
})
|
||||
|
||||
it('resolves user and tool-result images while preserving explicit fallbacks', async () => {
|
||||
const callId = CallId('missing-call')
|
||||
const knownCallId = CallId('known-call')
|
||||
const context = await toPiContext(request([
|
||||
{ role: 'user', content: [{ type: 'text', text: '' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: knownCallId, name: 'lookup', arguments: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', attachment: ref },
|
||||
{ type: 'text', text: 'caption' },
|
||||
{ type: 'reasoning', text: 'ignored' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: knownCallId,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: callId, content: [] },
|
||||
{ type: 'image', attachment: ref },
|
||||
],
|
||||
}],
|
||||
},
|
||||
]), attachments)
|
||||
|
||||
expect(context.messages).toEqual([
|
||||
{ role: 'user', content: '', timestamp: 0 },
|
||||
expect.objectContaining({ role: 'assistant' }),
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'caption' },
|
||||
],
|
||||
timestamp: 0,
|
||||
},
|
||||
{
|
||||
role: 'toolResult',
|
||||
toolCallId: 'known-call',
|
||||
toolName: 'lookup',
|
||||
content: [{ type: 'text', text: '(no output)' }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
{
|
||||
role: 'toolResult',
|
||||
toolCallId: 'missing-call',
|
||||
toolName: 'unknown',
|
||||
content: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
isError: true,
|
||||
timestamp: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps empty text-only users while separating result-only messages', () => {
|
||||
const callId = CallId('unknown-call')
|
||||
expect(toPiContext(request([
|
||||
{ role: 'user', content: [] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'answer' },
|
||||
{ type: 'tool-call', id: CallId('other-call'), name: 'lookup', arguments: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
}],
|
||||
},
|
||||
]))).toMatchObject({
|
||||
messages: [
|
||||
{ role: 'user', content: '' },
|
||||
{ role: 'assistant' },
|
||||
{ role: 'toolResult', toolName: 'unknown' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles in-history system and assistant messages explicitly on the image path', async () => {
|
||||
await expect(toPiContext(request([{
|
||||
role: 'system',
|
||||
content: [{ type: 'image', attachment: ref }],
|
||||
}]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
|
||||
await expect(toPiContext(request([
|
||||
{ role: 'system', content: [{ type: 'text', text: 'history system' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'plain' }] },
|
||||
]), attachments)).resolves.toMatchObject({
|
||||
messages: [
|
||||
{ role: 'user', content: 'history system' },
|
||||
{ role: 'assistant' },
|
||||
{ role: 'user', content: 'plain' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(() => toPiAssistant({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'image', attachment: ref }],
|
||||
})).toThrow(/assistant image output/)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
@@ -17,6 +25,8 @@ interface ProviderCase {
|
||||
|
||||
const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL
|
||||
const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.DEEPSEEK_API_KEY
|
||||
const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL ?? process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
const providerCases: ProviderCase[] = [
|
||||
{
|
||||
@@ -32,13 +42,14 @@ const providerCases: ProviderCase[] = [
|
||||
provider: 'anthropic',
|
||||
api: 'anthropic-messages',
|
||||
model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8',
|
||||
...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {},
|
||||
...anthropicApiKey === undefined ? {} : { apiKey: anthropicApiKey },
|
||||
...anthropicBaseURL === undefined ? {} : { baseURL: anthropicBaseURL },
|
||||
},
|
||||
]
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
async function harness(image?: StoredImageAttachment): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -50,6 +61,30 @@ async function harness(): Promise<Context> {
|
||||
...profile.headers === undefined ? {} : { headers: profile.headers },
|
||||
})),
|
||||
})
|
||||
if (image !== undefined) {
|
||||
const fixture = image
|
||||
class E2eAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits: ImageAttachmentLimits = {
|
||||
maxImageBytes: fixture.data.byteLength,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: fixture.data.byteLength,
|
||||
maxImagePixels: fixture.ref.width * fixture.ref.height,
|
||||
mediaTypes: [fixture.ref.mediaType],
|
||||
}
|
||||
|
||||
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
return Promise.reject(new Error('e2e attachment fixture is read-only'))
|
||||
}
|
||||
|
||||
readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
if (ref.attachmentId !== fixture.ref.attachmentId) {
|
||||
return Promise.reject(new Error('unknown e2e attachment fixture'))
|
||||
}
|
||||
return Promise.resolve(fixture)
|
||||
}
|
||||
}
|
||||
await ctx.plugin(E2eAttachmentStore)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -158,6 +193,41 @@ for (const profile of providerCases) {
|
||||
expect(textOf(second).toLowerCase()).toContain('ocean')
|
||||
expect(expectNativeReplay(second, profile).stopReason).toBe('stop')
|
||||
})
|
||||
|
||||
if (profile.provider === 'anthropic') {
|
||||
it('sends a real image through the authenticated Anthropic visual path', async () => {
|
||||
const data = new Uint8Array(await readFile(
|
||||
new URL('../../../../assets/community-wecom-survey.png', import.meta.url),
|
||||
))
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: data.byteLength,
|
||||
width: 256,
|
||||
height: 256,
|
||||
name: 'qr-code.png',
|
||||
}
|
||||
const ctx = await harness({ ref, data })
|
||||
const result = await assemble(ctx, {
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code',
|
||||
},
|
||||
{ type: 'image', attachment: ref, alt: 'machine-readable symbol' },
|
||||
],
|
||||
}],
|
||||
maxTokens: 256,
|
||||
})
|
||||
|
||||
expectFinish(result, 'stop')
|
||||
expect(textOf(result).toLowerCase()).toContain('qr code')
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
@@ -116,6 +117,16 @@ describe('TokenMeterService pricing', () => {
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: 'text', text: 'abcd' },
|
||||
{ type: 'reasoning', text: 'ab' },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1024,
|
||||
height: 513,
|
||||
},
|
||||
},
|
||||
{ type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' },
|
||||
{
|
||||
type: 'tool-result',
|
||||
@@ -126,7 +137,7 @@ describe('TokenMeterService pricing', () => {
|
||||
{ type: 'future-block', payload: 'abcd' } as unknown as ContentBlock,
|
||||
]
|
||||
const estimated = service.estimateMessage({ role: 'assistant', content: blocks })
|
||||
expect(estimated).toBeGreaterThan(30)
|
||||
expect(estimated).toBe(813)
|
||||
expect(service.estimateMessage(textMessage('abcd'))).toBe(9)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -35,6 +36,17 @@ describe('turnEndToStopReason', () => {
|
||||
describe('harnessBlockToAcpContent', () => {
|
||||
it('maps a text block to ACP text content', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
|
||||
const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`)
|
||||
expect(harnessBlockToAcpContent({
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
})).toEqual({ type: 'text', text: `[image attachment ${attachmentId}]` })
|
||||
})
|
||||
|
||||
it('returns undefined for non-text blocks (reasoning / plugin-added)', () => {
|
||||
|
||||
@@ -124,6 +124,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Images use explicit text markers** — the terminal cannot render inline raster images, so user, assistant, tool-result, and streaming image blocks render as `[image attachment <id>]` instead of disappearing.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
|
||||
- **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again.
|
||||
|
||||
@@ -424,6 +424,9 @@ function contentText(content: readonly ContentBlock[]): string {
|
||||
case 'tool-result':
|
||||
parts.push(contentText(block.content))
|
||||
break
|
||||
case 'image':
|
||||
parts.push(`[image attachment ${block.attachment.attachmentId}]`)
|
||||
break
|
||||
default: {
|
||||
const rawType = (block as { type?: unknown }).type
|
||||
parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
|
||||
@@ -648,7 +651,14 @@ class AssistantMessageComponent extends Container {
|
||||
constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) {
|
||||
super()
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const text = displayText(content
|
||||
.flatMap(block => block.type === 'text'
|
||||
? [block.text]
|
||||
: block.type === 'image'
|
||||
? [`[image attachment ${block.attachment.attachmentId}]`]
|
||||
: [])
|
||||
.join('\n\n')
|
||||
.trim())
|
||||
if (reasoning && showReasoning) {
|
||||
this.addChild(new Spacer(1))
|
||||
this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0))
|
||||
@@ -668,6 +678,7 @@ class AssistantMessageComponent extends Container {
|
||||
interface StreamingBlock {
|
||||
type: string
|
||||
text: string
|
||||
block?: ContentBlock
|
||||
}
|
||||
|
||||
class StreamingAssistantComponent extends Container {
|
||||
@@ -691,6 +702,8 @@ class StreamingAssistantComponent extends Container {
|
||||
this.blocks.set(chunk.index, block)
|
||||
} else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
|
||||
this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
|
||||
} else if (chunk.type === 'block-end' && chunk.block.type === 'image') {
|
||||
this.blocks.set(chunk.index, { type: 'image', text: '', block: chunk.block })
|
||||
}
|
||||
this.rebuild()
|
||||
}
|
||||
@@ -707,6 +720,7 @@ class StreamingAssistantComponent extends Container {
|
||||
.flatMap<ContentBlock>(([, block]) => {
|
||||
if (block.type === 'text') return [{ type: 'text', text: block.text }]
|
||||
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
|
||||
if (block.type === 'image' && block.block?.type === 'image') return [block.block]
|
||||
return []
|
||||
})
|
||||
const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme)
|
||||
|
||||
@@ -434,6 +434,29 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 3, blockType: 'image' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: {
|
||||
type: 'block-end',
|
||||
index: 3,
|
||||
block: {
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'sha256:stream-image' as never,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 3,
|
||||
step: 1,
|
||||
@@ -441,6 +464,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('live thought')
|
||||
expect(result.terminal.output).toContain('sha256:stream-image]')
|
||||
result.terminal.send('\x12')
|
||||
await tick()
|
||||
appendAssistant(
|
||||
@@ -729,6 +753,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
{ type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
|
||||
{ type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
|
||||
{ type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'sha256:user-image' as never,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
{ type: 'future-block' } as never,
|
||||
{} as never,
|
||||
],
|
||||
@@ -737,6 +771,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: 'styled reasoning' },
|
||||
{ type: 'text', text: 'styled answer' },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'sha256:assistant-image' as never,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
], { inputTokens: 2_000_000, outputTokens: 1_500_000 })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'done', status: 'completed' },
|
||||
@@ -756,6 +800,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('Heading')
|
||||
expect(result.terminal.output).toContain('nested_tool({})')
|
||||
expect(result.terminal.output).toContain('nested result')
|
||||
expect(result.terminal.output).toContain('sha256:user-image]')
|
||||
expect(result.terminal.output).toContain('[image attachment sha256:assistant-image]')
|
||||
expect(result.terminal.output).toContain('[future-block]')
|
||||
expect(result.terminal.output).toContain('[content]')
|
||||
expect(result.terminal.output).toContain('↑2.0m ↓1.5m')
|
||||
|
||||
Reference in New Issue
Block a user