fix: address ds-review-bot v7 findings on the merged image-input head
- gate model selection on steering-placement image carriers from enqueue until their steering/message event publishes; release the gate when an admission ends idle without publication (both behaviorally asserted) - reject session.updateQueue edits carrying non-text blocks at the RPC boundary (queue edits cannot bypass image admission) - extend the durable-directory walk past a first-created DSH_HOME to the deepest pre-existing ancestor - strip Windows-style separators from attachment display names on POSIX - verify attachment reads with a header-only probe (digest already proves the bytes decoded fully at admission); document the read path - make SessionInputShell.addImages refusal observable and keep workspace transfers/composer intake from leaking refused drafts - own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy, pi-ai, compact-basic, and the DeepSeek text-only assertion - drop the redundant canonical-base64 regex and the no-op role read - move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts); document why AttachmentError does not extend HarnessError - document the hard attachments inject in both consumer READMEs
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/** Raster decoding used before bytes enter durable storage. */
|
||||
/** Raster inspection: full decode at admission, header-only probe on verified reads. */
|
||||
|
||||
import sharp from 'sharp'
|
||||
import sharp, { type Sharp } from 'sharp'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
|
||||
@@ -18,26 +18,47 @@ const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
|
||||
gif: 'image/gif',
|
||||
}
|
||||
|
||||
async function imageMetadata(image: Sharp): Promise<DetectedImage> {
|
||||
const metadata = await image.metadata()
|
||||
const mediaType = MEDIA_TYPES[metadata.format as string]
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
return { mediaType, width: metadata.width, height: metadata.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a supported raster and return its intrinsic metadata.
|
||||
* Parse a supported raster's header and return its intrinsic metadata without
|
||||
* decoding pixels. Digest-verified reads use this: admission already proved
|
||||
* that these exact bytes decode completely, so the read path only re-derives
|
||||
* the reference fields instead of paying the full-raster decode again.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @param maxPixels - optional write-time decoded-pixel limit; reads omit it.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
|
||||
export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
|
||||
try {
|
||||
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
|
||||
const metadata = await image.metadata()
|
||||
const mediaType = MEDIA_TYPES[metadata.format as string]
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
const { width, height } = metadata
|
||||
if (maxPixels !== undefined && width * height > maxPixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
await image.raw().toBuffer()
|
||||
return { mediaType, width, height }
|
||||
return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false }))
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully decode a supported raster and return its intrinsic metadata.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @param maxPixels - decoded-pixel admission limit.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
|
||||
try {
|
||||
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
|
||||
const detected = await imageMetadata(image)
|
||||
if (maxPixels !== undefined && detected.width * detected.height > maxPixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
await image.raw().toBuffer()
|
||||
return detected
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import {
|
||||
AttachmentError,
|
||||
AttachmentId,
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { detectImage } from './image.ts'
|
||||
import { detectImage, probeImage } from './image.ts'
|
||||
|
||||
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
|
||||
|
||||
@@ -24,7 +24,11 @@ function digest(data: Uint8Array): string {
|
||||
|
||||
function displayName(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
|
||||
// Strip both separator styles by hand: a POSIX host treats `\` as an
|
||||
// ordinary character, so path.basename would keep a Windows client's full
|
||||
// local path and leak it into the reference and the session log.
|
||||
const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
|
||||
const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
|
||||
return clean === '' ? undefined : clean
|
||||
}
|
||||
|
||||
@@ -79,6 +83,31 @@ async function syncDirectory(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from a preferred boundary to the deepest ancestor that already
|
||||
* exists. A first save may create DSH_HOME itself (recursive mkdir), and a
|
||||
* directory this process creates is not durable until its parent entry syncs
|
||||
* — so only a pre-existing directory may be vouched as the durable stop.
|
||||
* @param path - preferred absolute boundary.
|
||||
* @returns `path` when it exists, else its closest existing ancestor.
|
||||
*/
|
||||
async function existingBoundary(path: string): Promise<string> {
|
||||
let level = resolve(path)
|
||||
while (true) {
|
||||
try {
|
||||
await stat(level)
|
||||
return level
|
||||
} catch {
|
||||
// Swallows only the stat probe's failure: a missing (or unreadable)
|
||||
// level simply moves the boundary up; mkdir later surfaces real errors.
|
||||
}
|
||||
const parent = dirname(level)
|
||||
/* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */
|
||||
if (parent === level) return level
|
||||
level = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one private directory tree and persist every ancestor entry up to a
|
||||
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
|
||||
@@ -118,10 +147,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
// The durable boundary is the root's grandparent (DSH_HOME for the
|
||||
// The preferred boundary is the root's grandparent (DSH_HOME for the
|
||||
// documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be
|
||||
// first-created by a concurrent save, so their entries sync on every path.
|
||||
const boundary = dirname(dirname(resolve(root)))
|
||||
// When DSH_HOME itself does not exist yet, the boundary retreats to its
|
||||
// closest existing ancestor so the first save syncs the new home entry too.
|
||||
const boundary = await existingBoundary(dirname(dirname(resolve(root))))
|
||||
await ensureDurableDirectory(bucket, boundary)
|
||||
await ensureDurableDirectory(staging, boundary)
|
||||
const temporary = join(staging, randomUUID())
|
||||
@@ -188,8 +219,12 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
const metadata = await inspectMetadata(data, ref.mediaType)
|
||||
if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
// The digest proves these are the exact bytes admission fully decoded, so
|
||||
// the read path only re-derives the header fields (no raster decode, no
|
||||
// per-request pixel amplification on history replay).
|
||||
const metadata = await probeImage(data)
|
||||
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|
||||
|| metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
return { ref, data }
|
||||
|
||||
15
packages/attachment/attachment/src/brand.ts
Normal file
15
packages/attachment/attachment/src/brand.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Opaque content-addressed identifier for one immutable attachment object. */
|
||||
export type AttachmentId = Branded<'AttachmentId'>
|
||||
|
||||
/**
|
||||
* Brand a validated storage identifier.
|
||||
* @param value - backend-produced opaque identifier.
|
||||
* @returns the branded identifier.
|
||||
*/
|
||||
export function AttachmentId(value: string): AttachmentId {
|
||||
return value as AttachmentId
|
||||
}
|
||||
26
packages/attachment/attachment/src/error.ts
Normal file
26
packages/attachment/attachment/src/error.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
|
||||
|
||||
/**
|
||||
* Stable failures suitable for host RPC error mapping.
|
||||
*
|
||||
* Deliberately re-implements the `HarnessError` shape instead of extending it:
|
||||
* the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this
|
||||
* package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base
|
||||
* would create a dependency cycle. Consumers route on `code`, never on the
|
||||
* prototype chain, so the shapes stay interchangeable at the wire boundary.
|
||||
*/
|
||||
export class AttachmentError extends Error {
|
||||
/** Stable machine-routing failure code. */
|
||||
readonly code: string
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure description without raw bytes or host paths.
|
||||
* @param code - stable machine-routing code.
|
||||
* @param options - optional chained cause.
|
||||
*/
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AttachmentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
StoredImageAttachment,
|
||||
} from './types.ts'
|
||||
|
||||
export { AttachmentError, AttachmentId } from './types.ts'
|
||||
export { AttachmentId } from './brand.ts'
|
||||
export { AttachmentError } from './error.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
ImageAttachmentLimits,
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AttachmentId } from './brand.ts'
|
||||
|
||||
/** Opaque content-addressed identifier for one immutable attachment object. */
|
||||
export type AttachmentId = Branded<'AttachmentId'>
|
||||
|
||||
/**
|
||||
* Brand a validated storage identifier.
|
||||
* @param value - backend-produced opaque identifier.
|
||||
* @returns the branded identifier.
|
||||
*/
|
||||
export function AttachmentId(value: string): AttachmentId {
|
||||
return value as AttachmentId
|
||||
}
|
||||
export type { AttachmentId } from './brand.ts'
|
||||
|
||||
/** Raster image formats accepted by the version-one attachment path. */
|
||||
export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
||||
@@ -56,20 +46,3 @@ export interface StoredImageAttachment {
|
||||
ref: ImageAttachmentRef
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
/** Stable failures suitable for host RPC error mapping. */
|
||||
export class AttachmentError extends Error {
|
||||
/** Stable machine-routing failure code. */
|
||||
readonly code: string
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure description without raw bytes or host paths.
|
||||
* @param code - stable machine-routing code.
|
||||
* @param options - optional chained cause.
|
||||
*/
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AttachmentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26
|
||||
README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0
|
||||
README.md: 4a4ce324a0482072164d3c4f6badd464bfacfc6f
|
||||
README.zh.md: 7fb421ae206563a625df5bd53fbcef1f585bd269
|
||||
|
||||
@@ -22,5 +22,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`attachments` is a hard inject** — the route plugin (and `host-apiproxy`) will not mount until an attachment backend provides `ctx.attachments`, and a composition missing one stalls silently as a cordis inject gap rather than failing loud; text-only deployments therefore still carry the native `sharp` dependency through `attachment-local`. A capability-degraded (image-refusing) mount is deliberate deferred work.
|
||||
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
|
||||
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
|
||||
|
||||
@@ -22,5 +22,6 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`attachments` 是硬性注入依赖**:路由插件(以及 `host-apiproxy`)在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败;因此纯文本部署也会经由 `attachment-local` 携带原生 `sharp` 依赖。降级为拒绝图片的挂载方式是有意延期的工作。
|
||||
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
|
||||
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
|
||||
|
||||
@@ -147,8 +147,10 @@ export function apply(ctx: Context): void {
|
||||
next.setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
if (imageIds.length > 0) {
|
||||
next.addImages(imageIds)
|
||||
// Transfer only on acceptance: a destination shell mid-submission
|
||||
// refuses, and the drafts must stay owned (and releasable) by the
|
||||
// source shell instead of silently leaking their object URLs.
|
||||
if (imageIds.length > 0 && next.addImages(imageIds)) {
|
||||
for (const id of imageIds) from.removeImage(id)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +201,12 @@ export function apply(ctx: Context): void {
|
||||
addImages: (files) => {
|
||||
try {
|
||||
const images = conversation.createDraftImages(files)
|
||||
shell.addImages(images.map(image => image.id))
|
||||
if (!shell.addImages(images.map(image => image.id))) {
|
||||
// Refused intake (machineBusy raced a submission): release the
|
||||
// just-created previews instead of stranding their object URLs.
|
||||
conversation.releaseDraftImages(images)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -32,8 +32,12 @@ export interface InputTarget {
|
||||
export interface SessionInput extends InputTarget {
|
||||
/** Single write path for draft text (all mutation rides machine events). */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned draft attachment ids. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): void
|
||||
/**
|
||||
* Append ordered browser-owned draft attachment ids.
|
||||
* @returns whether the ids were appended; busy admission phases refuse, and
|
||||
* the caller keeps ownership of refused ids (release or retry them).
|
||||
*/
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean
|
||||
/** Remove one browser-owned draft attachment id. */
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser objects no longer exist. */
|
||||
@@ -69,8 +73,11 @@ export interface InputService {
|
||||
export interface InputActions {
|
||||
/** Single public draft write path (full next draft; occurrence math via diff scan). */
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned draft attachment ids. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): void
|
||||
/**
|
||||
* Append ordered browser-owned draft attachment ids.
|
||||
* @returns whether the ids were appended (busy admission phases refuse).
|
||||
*/
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean
|
||||
/** Remove one browser-owned draft attachment id. */
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser objects no longer exist. */
|
||||
|
||||
@@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput {
|
||||
/** The public provide-channel action face (one stable identity per session — decision 20). */
|
||||
readonly actions: InputActions = {
|
||||
setDraft: (text) => { this.setDraft(text) },
|
||||
addImages: (ids) => { this.addImages(ids) },
|
||||
addImages: ids => this.addImages(ids),
|
||||
removeImage: (id) => { this.removeImage(id) },
|
||||
pruneImages: (ids) => { this.pruneImages(ids) },
|
||||
submit: (mode) => { this.submit(mode) },
|
||||
@@ -101,11 +101,16 @@ export class SessionInputShell implements SessionInput {
|
||||
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
|
||||
}
|
||||
|
||||
/** Append ordered browser-owned draft attachment ids. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): void {
|
||||
if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return
|
||||
/**
|
||||
* Append ordered browser-owned draft attachment ids.
|
||||
* @returns whether the ids were appended (busy admission phases refuse).
|
||||
*/
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean {
|
||||
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
|
||||
if (ids.length === 0) return true
|
||||
this.imageIds = [...this.imageIds, ...ids]
|
||||
this.publish()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove one browser-owned draft attachment id. */
|
||||
|
||||
@@ -114,7 +114,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: {
|
||||
setDraft: () => {},
|
||||
addImages: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('render branch tails', () => {
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{
|
||||
setDraft: () => {},
|
||||
addImages: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
@@ -122,7 +122,7 @@ describe('render branch tails', () => {
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{
|
||||
setDraft: () => {},
|
||||
addImages: () => {},
|
||||
addImages: () => true,
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
|
||||
@@ -339,6 +339,26 @@ describe('machine pending lock', () => {
|
||||
expect(textarea.readOnly).toBe(true)
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('addImages reports refusal in busy phases so callers keep draft ownership', () => {
|
||||
const { shell } = bench()
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
{
|
||||
token: '/goal ',
|
||||
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
|
||||
},
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
shell.submit('queue')
|
||||
})
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
// A refused batch must be observable (the workspace-switch transfer keeps
|
||||
// the source shell's drafts alive instead of leaking their object URLs).
|
||||
expect(shell.addImages(['busy-1' as never])).toBe(false)
|
||||
expect(shell.snapshot.imageIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorations', () => {
|
||||
|
||||
@@ -395,7 +395,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
|
||||
inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
@@ -566,7 +566,7 @@ describe('DetailsPanel Output section', () => {
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
|
||||
inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
@@ -205,14 +205,8 @@ function finishError(finish: FinishReason): Error | undefined {
|
||||
function summaryText(
|
||||
blocks: readonly ContentBlock[],
|
||||
): Array<Extract<ContentBlock, { type: 'text' }>> {
|
||||
if (containsImage(blocks)) {
|
||||
if (contentHasImage(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)))
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 3badccd4144babc474f52fa44598ddec3c253e65
|
||||
README.zh.md: 8ccd5bf95c70c79e968b3ea8d0f6a48c62359ae3
|
||||
README.md: 7ee67009c33d9df843e1d5aa71e727c0c798d4a0
|
||||
README.zh.md: f7d1503d2947f84dbc21cb74e10678c4e14cb248
|
||||
|
||||
@@ -40,6 +40,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`attachments` is a hard inject** — the proxy will not mount until an attachment backend provides `ctx.attachments`; a composition missing one stalls silently as a cordis inject gap rather than failing loud (same gap as the connection route; a capability-degraded mount is deferred work).
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
|
||||
@@ -40,6 +40,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`attachments` 是硬性注入依赖**:代理在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败(与 connection 路由是同一缺口;降级挂载属于延期工作)。
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { contentHasImage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
@@ -65,11 +65,11 @@ const DEFAULT_MAX_MESSAGES = 50
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
function decodeBase64(data: string): Uint8Array {
|
||||
if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
|
||||
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
|
||||
}
|
||||
const decoded = Buffer.from(data, 'base64')
|
||||
if (decoded.toString('base64') !== data) {
|
||||
// One canonical-form check: any non-canonical input (whitespace, url-safe
|
||||
// alphabet, bad padding, truncated groups) fails the exact round-trip, so a
|
||||
// pre-filter regex over the multi-MiB upload string would be pure overhead.
|
||||
if (data.length === 0 || decoded.toString('base64') !== data) {
|
||||
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
|
||||
}
|
||||
return new Uint8Array(decoded)
|
||||
@@ -147,12 +147,6 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** True when typed model content contains an image, including nested tool results. */
|
||||
function contentHasImage(content: readonly ContentBlock[]): boolean {
|
||||
return content.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && contentHasImage(block.content)))
|
||||
}
|
||||
|
||||
/** True when the current model-visible surface contains an image. */
|
||||
function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
|
||||
return messages.some(message => contentHasImage(message.content))
|
||||
@@ -627,11 +621,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, InboxItem[]>()
|
||||
/**
|
||||
* Claimed-but-unpublished queued occurrences: dequeue is not publication —
|
||||
* the `user/message` append follows it asynchronously — so an image carrier
|
||||
* stays a model-selection gate until its durable event lands, its discard
|
||||
* arrives, or the admission's turn settles idle. Kept apart from the mirror
|
||||
* so the mux-open snapshot never replays a claimed occurrence as queued.
|
||||
* Unpublished occurrences that must still gate model selection: a queued
|
||||
* item from dequeue (claim is not publication — the `user/message` append
|
||||
* follows asynchronously) and a steering item from enqueue (it never enters
|
||||
* the queued mirror, and its `steering/message` append is a separate outbox
|
||||
* hop). Entries retire on their durable event, discard, or idle. Kept apart
|
||||
* from the mirror so the mux-open snapshot never replays them as queued.
|
||||
*/
|
||||
const pendingPublication = new Map<SessionId, InboxItem[]>()
|
||||
type UnseenQueueEvent =
|
||||
@@ -692,7 +687,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
|
||||
if (item.placement !== 'queued') return
|
||||
if (item.placement === 'steering') {
|
||||
// A steering carrier never enters the queued mirror, yet its image
|
||||
// must gate model selection from enqueue until its steering/message
|
||||
// event publishes (or the admission ends): the outbox hop between
|
||||
// steer() and the append is asynchronous, and a text-only switch
|
||||
// accepted inside it would strand every later turn.
|
||||
const pending = pendingPublication.get(agent.id) ?? []
|
||||
pending.push(item)
|
||||
pendingPublication.set(agent.id, pending)
|
||||
return
|
||||
}
|
||||
const unseen = takeUnseen(agent.id, item.id)
|
||||
if (unseen?.kind === 'terminal') return
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
@@ -726,10 +731,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (retire(agent, item)) publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type !== 'user/message') return
|
||||
const id = event.type === 'user/message'
|
||||
? event.data.id
|
||||
: event.type === 'steering/message'
|
||||
? (event.data as { message: UserMessage }).message.id
|
||||
: undefined
|
||||
if (id === undefined) return
|
||||
const pending = pendingPublication.get(session.id)
|
||||
if (pending === undefined) return
|
||||
const index = pending.findIndex(entry => entry.message.id === (event.data).id)
|
||||
const placement = event.type === 'user/message' ? 'queued' : 'steering'
|
||||
const index = pending.findIndex(entry => entry.message.id === id && entry.placement === placement)
|
||||
if (index === -1) return
|
||||
pending.splice(index, 1)
|
||||
if (pending.length === 0) pendingPublication.delete(session.id)
|
||||
@@ -1385,6 +1396,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
updateQueue(request) {
|
||||
const { sessionId, itemId, action } = request.payload
|
||||
// Queue edits bypass durablePromptContent (no admission, no durable
|
||||
// reference, no model-capability recheck), so only text blocks may be
|
||||
// written through this boundary; image intake is prompt-only.
|
||||
if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'queue edits accept text content only',
|
||||
details: { reason: 'QUEUE_EDIT_NON_TEXT' },
|
||||
}))
|
||||
}
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
|
||||
return Promise.resolve(err(request, {
|
||||
|
||||
@@ -387,6 +387,70 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('gates selection on a steering image from enqueue until its event publishes', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
registerTextOnly(ctx)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const steering = {
|
||||
id: 's-1', role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'image', attachment: { attachmentId: 'att-s', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
|
||||
} as never
|
||||
const steeringItem = { id: 'i-s-1', message: steering, placement: 'steering' } as never
|
||||
// A steering carrier never enters the queued mirror, yet the outbox hop
|
||||
// between steer() and its append must not open a text-only switch window.
|
||||
ctx.emit('agent/inbox/enqueue', agent, steeringItem)
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
|
||||
ctx.emit('agent/inbox/dequeue', agent, steeringItem)
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
|
||||
// Publication hands the gate over to the durable surface.
|
||||
agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' })
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('re-opens selection when an admission ends idle without publication', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
registerTextOnly(ctx)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const rejected = {
|
||||
id: 'r-1', role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'image', attachment: { attachmentId: 'att-r', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
|
||||
} as never
|
||||
const rejectedItem = { id: 'i-r-1', message: rejected, placement: 'queued' } as never
|
||||
ctx.emit('agent/inbox/enqueue', agent, rejectedItem)
|
||||
ctx.emit('agent/inbox/dequeue', agent, rejectedItem)
|
||||
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
|
||||
|
||||
// Idle proves the admission ended without publication; nothing durable
|
||||
// requires an image route, so the text-only switch must be accepted again.
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
expect(expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a queue edit that injects unadmitted image content', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
Object.assign(agent, { updateInbox: () => 'applied' })
|
||||
const denied = await api.sessions.updateQueue(request({
|
||||
sessionId,
|
||||
itemId: 'i-x' as never,
|
||||
action: {
|
||||
kind: 'edit' as const,
|
||||
content: [{ type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }] as never,
|
||||
},
|
||||
}))
|
||||
expect(denied.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'QUEUE_EDIT_NON_TEXT' } },
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serializes an image save with a concurrent model selection', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
registerTextOnly(ctx)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { WireMessage, WireRequest, WireTool } from './types.ts'
|
||||
|
||||
@@ -62,11 +62,8 @@ function flattenText(blocks: ContentBlock[]): string {
|
||||
|
||||
/** Reject core image content before any text-flattening path can silently erase it. */
|
||||
function assertTextOnly(blocks: readonly ContentBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'image') {
|
||||
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
if (block.type === 'tool-result') assertTextOnly(block.content)
|
||||
if (contentHasImage(blocks)) {
|
||||
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ import type {
|
||||
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveProfiles } from './config.ts'
|
||||
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
import { contentHasImage, toPiContext } from './context.ts'
|
||||
import { contentHasImage } from '@deepseek-ai/dsh-llm'
|
||||
import { toPiContext } from './context.ts'
|
||||
import { toStreamChunks } from './stream.ts'
|
||||
|
||||
/** Constructor options for {@link PiAiAdapter}. */
|
||||
@@ -189,11 +190,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
|
||||
|
||||
try {
|
||||
const containsImage = options.messages.some((message) => {
|
||||
// The discriminant is part of same-process message validity and is read before content.
|
||||
void message.role
|
||||
return contentHasImage(message.content)
|
||||
})
|
||||
const containsImage = options.messages.some(message => contentHasImage(message.content))
|
||||
if (containsImage && !model.input.includes('image')) {
|
||||
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module dsh-llm-pi-ai/context
|
||||
*/
|
||||
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
|
||||
@@ -18,15 +18,6 @@ function flattenText(message: Message): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether content contains an image, including nested tool results.
|
||||
* @param blocks - content to inspect recursively.
|
||||
* @returns whether any nested block is an image.
|
||||
*/
|
||||
export function contentHasImage(blocks: readonly ContentBlock[]): boolean {
|
||||
return blocks.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && contentHasImage(block.content)))
|
||||
}
|
||||
|
||||
/** Flatten text recursively inside one tool result. */
|
||||
function toolResultText(blocks: readonly ContentBlock[]): string {
|
||||
|
||||
@@ -638,7 +638,7 @@ describe('provider profile lifecycle', () => {
|
||||
describe('abort wiring', () => {
|
||||
it('preserves an unknown pre-dispatch adapter Error exactly', async () => {
|
||||
const original = new Error('SDK context conversion exploded')
|
||||
const message = Object.defineProperty({}, 'role', {
|
||||
const message = Object.defineProperty({}, 'content', {
|
||||
get() { throw original },
|
||||
})
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
|
||||
@@ -656,7 +656,7 @@ describe('abort wiring', () => {
|
||||
it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => {
|
||||
const controller = new AbortController()
|
||||
const original = new Error('conversion lost its caller')
|
||||
const message = Object.defineProperty({}, 'role', {
|
||||
const message = Object.defineProperty({}, 'content', {
|
||||
get() {
|
||||
controller.abort('caller cancelled during conversion')
|
||||
throw original
|
||||
|
||||
16
packages/llm/llm/src/content.ts
Normal file
16
packages/llm/llm/src/content.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
|
||||
|
||||
import type { ContentBlock } from './types.ts'
|
||||
|
||||
/**
|
||||
* True when typed model content contains an image block, walking nested
|
||||
* tool-result content. This is the one recursive image walk shared by every
|
||||
* image policy (capability gating, text-only serialization, compaction
|
||||
* survey), so a consumer cannot silently diverge on nesting depth.
|
||||
* @param content - typed model content blocks.
|
||||
* @returns whether any nested block is an image.
|
||||
*/
|
||||
export function contentHasImage(content: readonly ContentBlock[]): boolean {
|
||||
return content.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && contentHasImage(block.content)))
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './types.ts'
|
||||
export * from './content.ts'
|
||||
export * from './message.ts'
|
||||
export * from './retry-policy.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
|
||||
Reference in New Issue
Block a user