From 65d2015b093ebbd34984a5f38a9ceeece2d70bfa Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:36:51 +0800 Subject: [PATCH 01/37] feat(attachment): store and project generic files --- .../src/compression-limiter.ts | 15 +- .../attachment-local/src/file-store.ts | 181 ++++++++++++ .../attachment/attachment-local/src/index.ts | 31 +- .../attachment/attachment-local/src/store.ts | 222 +++++++++++--- .../attachment-local/tests/file-store.spec.ts | 278 ++++++++++++++++++ .../attachment-local/tests/index.spec.ts | 15 + .../attachment-local/tests/store.spec.ts | 25 +- .../attachment/attachment/src/admission.ts | 35 ++- packages/attachment/attachment/src/error.ts | 2 + packages/attachment/attachment/src/index.ts | 71 ++++- packages/attachment/attachment/src/types.ts | 39 +++ .../attachment/tests/admission.spec.ts | 40 ++- .../attachment/attachment/tests/index.spec.ts | 25 +- packages/llm/llm/src/content.ts | 76 ++++- packages/llm/llm/src/index.ts | 55 +++- packages/llm/llm/src/types.ts | 16 +- packages/llm/llm/tests/content.spec.ts | 80 +++++ packages/llm/llm/tests/service.spec.ts | 67 +++++ packages/llm/token-meter/src/index.ts | 15 +- packages/llm/token-meter/src/route-pricing.ts | 32 +- packages/llm/token-meter/src/surface-fold.ts | 54 +++- .../token-meter/tests/route-pricing.spec.ts | 38 ++- .../session-log-export/src/archive.ts | 126 +++++--- .../session-log-export/src/index.ts | 2 + .../tests/archive.host.spec.ts | 57 +++- 25 files changed, 1469 insertions(+), 128 deletions(-) create mode 100644 packages/attachment/attachment-local/src/file-store.ts create mode 100644 packages/attachment/attachment-local/tests/file-store.spec.ts diff --git a/packages/attachment/attachment-local/src/compression-limiter.ts b/packages/attachment/attachment-local/src/compression-limiter.ts index 3935f262a1..26cc1c6ecb 100644 --- a/packages/attachment/attachment-local/src/compression-limiter.ts +++ b/packages/attachment/attachment-local/src/compression-limiter.ts @@ -1,5 +1,16 @@ /** Instance-owned concurrency bound for native image transformations. */ +/** + * Preserve Error rejections and normalize non-Error native binding values. + * @param reason - rejection reason returned by a compression task. + * @returns an Error suitable for promise rejection. + */ +export function compressionFailure(reason: unknown): Error { + return reason instanceof Error + ? reason + : new Error('Image compression task rejected with a non-Error value.', { cause: reason }) +} + /** FIFO limiter for asynchronous compression work. */ export class CompressionLimiter { private active = 0 @@ -30,9 +41,7 @@ export class CompressionLimiter { }, (error: unknown) => { release() - reject(error instanceof Error - ? error - : new Error('Image compression task rejected with a non-Error value.', { cause: error })) + reject(compressionFailure(error)) }, ) } diff --git a/packages/attachment/attachment-local/src/file-store.ts b/packages/attachment/attachment-local/src/file-store.ts new file mode 100644 index 0000000000..cf4c56edb1 --- /dev/null +++ b/packages/attachment/attachment-local/src/file-store.ts @@ -0,0 +1,181 @@ +/** Verbatim content-addressed local file storage. @module @deepseek-ai/dsh-attachment-local/file-store */ + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, +} from '@deepseek-ai/dsh-attachment' +import { + publishImmutableAlias, publishImmutableObject, publishImmutableObjectStream, +} from './store.ts' + +const FILE_ID_PATTERN = /^sha256:([a-f0-9]{64})$/ +const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu + +function isWindowsDeviceName(name: string): boolean { + const dot = name.indexOf('.') + const stem = (dot < 0 ? name : name.slice(0, dot)).replace(/[. ]+$/u, '') + return WINDOWS_DEVICE_NAME.test(stem) +} + +function utf8Prefix(value: string, maxBytes: number): string { + let bytes = 0 + let prefix = '' + for (const character of Buffer.from(value).toString('utf8')) { + const characterBytes = Buffer.byteLength(character) + if (bytes + characterBytes > maxBytes) break + prefix += character + bytes += characterBytes + } + return prefix +} + +/** + * Sanitize one caller display name into a safe stored leaf name. Both + * separator styles are stripped 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. Characters + * Windows refuses in file names become `_` so one reference stays valid on + * every supported host. + * @param value - caller-declared display name, possibly a full client path. + * @returns a non-empty leaf name safe to store on every supported filesystem. + */ +export function fileLeafName(value: string | undefined): string { + if (value === undefined) return 'file' + const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1) + let clean = leaf + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[<>:"|?*]/g, '_') + .trim() + .replace(/[. ]+$/u, '') + if (isWindowsDeviceName(clean)) clean = `_${clean}` + clean = utf8Prefix(clean, 255).replace(/[. ]+$/u, '') + return clean === '' || clean === '.' || clean === '..' ? 'file' : clean +} + +function ensureFileReference(ref: FileAttachmentRef): string { + const match = FILE_ID_PATTERN.exec(String(ref.attachmentId)) + if (match?.[1] === undefined || ref.name !== fileLeafName(ref.name)) { + throw new AttachmentError('File attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') + } + return match[1] +} + +/** + * Derive the absolute immutable-object path for one stored file. The digest + * names a directory so the sanitized display name stays the stored leaf name, + * giving models and users a path that ends in the real filename. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable file reference from the session log or an upload receipt. + * @returns provider-local path without reading the object. + * @throws an AttachmentError when the reference digest or name is invalid. + */ +export function storedFilePath(root: string, ref: FileAttachmentRef): string { + const sha256 = ensureFileReference(ref) + return join(root, 'files', sha256.slice(0, 2), sha256, ref.name) +} + +/** Canonical object path shared by every display name for one digest. */ +function storedFileObjectPath(root: string, sha256: string): string { + return join(root, 'file-objects', sha256.slice(0, 2), sha256) +} + +/** + * Commit one file byte-for-byte below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - exact bytes and optional display name. + * @returns the durable content-addressed file reference. + */ +export async function saveFileVerbatim( + root: string, + input: SaveFileAttachment, +): Promise { + const sha256 = createHash('sha256').update(input.data).digest('hex') + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${sha256}`), + name: fileLeafName(input.name), + bytes: input.data.byteLength, + } + const objectPath = storedFileObjectPath(root, sha256) + await publishImmutableObject(root, objectPath, input.data, sha256) + await publishImmutableAlias(root, objectPath, storedFilePath(root, ref), sha256) + return ref +} + +/** + * Commit one file byte-for-byte from bounded chunks below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +export async function saveFileStreamVerbatim( + root: string, + input: SaveFileStreamAttachment, +): Promise { + const name = fileLeafName(input.name) + const stored = await publishImmutableObjectStream( + root, + input.data, + sha256 => storedFileObjectPath(root, sha256), + input.signal, + ) + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${stored.sha256}`), + name, + bytes: stored.bytes, + } + input.signal?.throwIfAborted() + await publishImmutableAlias( + root, + storedFileObjectPath(root, stored.sha256), + storedFilePath(root, ref), + stored.sha256, + ) + input.signal?.throwIfAborted() + return ref +} + +/** + * Read one stored file in bounded chunks and verify its byte count and digest. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable file reference from the session log. + * @param signal - optional cancellation for filesystem reads. + * @returns exact stored bytes in order; integrity failures reject after the final chunk. + */ +export async function* readFileStreamVerbatim( + root: string, + ref: FileAttachmentRef, + signal?: AbortSignal, +): AsyncIterable { + signal?.throwIfAborted() + const sha256 = ensureFileReference(ref) + const stream = createReadStream(storedFilePath(root, ref), { + highWaterMark: 1 << 16, + ...(signal === undefined ? {} : { signal }), + }) + const hash = createHash('sha256') + let bytes = 0 + try { + for await (const chunk of stream) { + signal?.throwIfAborted() + const data = chunk as Buffer + hash.update(data) + bytes += data.byteLength + yield data + } + } catch (error) { + signal?.throwIfAborted() + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new AttachmentError('File attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + } + throw new AttachmentError('Unable to read file attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) + } finally { + stream.destroy() + } + signal?.throwIfAborted() + if (bytes !== ref.bytes || hash.digest('hex') !== sha256) { + throw new AttachmentError('Stored file attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 16b963f225..7058fb3244 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,23 +5,32 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' -import { CompressionLimiter } from './compression-limiter.ts' +import { CompressionLimiter, compressionFailure } from './compression-limiter.ts' import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { + readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from './file-store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { canPassThroughNormalization, normalizeImage } from './normalization.ts' export type { NormalizedImage, NormalizationPolicy } from './normalization.ts' export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' export type { PreparedImageFile } from './store.ts' +export { + fileLeafName, readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from './file-store.ts' export { readRequestImageFile, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ @@ -124,9 +133,7 @@ class SharedRequest { }, (error: unknown) => { signal.removeEventListener('abort', abort) release(false) - // CompressionLimiter normalizes task rejections before this handler. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - reject(error) + reject(compressionFailure(error)) }) }) } @@ -222,6 +229,22 @@ export class LocalAttachmentStore extends AttachmentStore { return normalizedImagePath(this.root, ref) } + override async saveFile(input: SaveFileAttachment): Promise { + return saveFileVerbatim(this.root, input) + } + + override async saveFileStream(input: SaveFileStreamAttachment): Promise { + return saveFileStreamVerbatim(this.root, input) + } + + override readFileStream(ref: FileAttachmentRef, signal?: AbortSignal): AsyncIterable { + return readFileStreamVerbatim(this.root, ref, signal) + } + + override fileHostPath(ref: FileAttachmentRef): string { + return storedFilePath(this.root, ref) + } + override async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index e5a979aec1..7fab679e34 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -1,7 +1,7 @@ /** Content-addressed, owner-private local attachment storage. */ import { createHash, randomUUID } from 'node:crypto' -import { constants } from 'node:fs' +import { constants, createReadStream } from 'node:fs' import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' import { dirname, join, parse, resolve } from 'node:path' import { @@ -197,60 +197,210 @@ export async function commitPreparedImageFile( if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) { throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') } - const bucket = join(root, 'objects', sha256.slice(0, 2)) + await publishImmutableObject(root, normalizedImagePath(root, prepared.ref), normalized, sha256) + return prepared.ref +} + +/** + * Publish one immutable content-addressed object below a versioned attachment + * root: staged write, fsync, hard-link into place, digest-verified EEXIST + * deduplication, read-only mode, and durable directory entries from the + * target's parent up to (excluding) `root`. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param target - absolute final object path below `root`. + * @param data - exact object bytes whose digest is `sha256`. + * @param sha256 - hex digest the stored bytes must match on deduplication. + */ +export async function publishImmutableObject( + root: string, + target: string, + data: Uint8Array, + sha256: string, +): Promise { + const staged = await stageImmutableObject(root, (function* (): Iterable { + yield data + })()) + if (staged.sha256 !== sha256) { + await removeTemporary(staged.path) + throw new AttachmentError('Attachment bytes do not match their publication digest.', 'ATTACHMENT_CORRUPT') + } + await publishStagedObject(root, target, staged) +} + +/** Digest and byte count produced while streaming one immutable object to disk. */ +export interface StreamedImmutableObject { + readonly sha256: string + readonly bytes: number +} + +/** + * Stream one immutable object from bounded chunks into a staging file, then + * publish it at a digest-derived target without collecting the complete object in memory. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param data - exact object bytes in order. + * @param targetFor - derive the final absolute target from the completed digest and byte count. + * @param signal - optional cancellation for source reads and storage writes. + * @returns digest and exact byte count of the published object. + */ +export async function publishImmutableObjectStream( + root: string, + data: AsyncIterable, + targetFor: (sha256: string, bytes: number) => string, + signal?: AbortSignal, +): Promise { + const staged = await stageImmutableObject(root, data, signal) + let target: string + try { + target = targetFor(staged.sha256, staged.bytes) + } catch (error) { + /* v8 ignore start -- The local target callback constructs a validated reference from this function's digest. */ + await removeTemporary(staged.path) + throw error + /* v8 ignore stop */ + } + await publishStagedObject(root, target, staged) + return { sha256: staged.sha256, bytes: staged.bytes } +} + +/** + * Publish another durable hard-link name for an existing immutable object. + * @param root - absolute versioned attachment root. + * @param source - existing content-addressed object below `root`. + * @param target - new alias below `root`. + * @param sha256 - expected object digest for an existing-target race. + */ +export async function publishImmutableAlias( + root: string, + source: string, + target: string, + sha256: string, +): Promise { + const parent = dirname(target) + try { + const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) + await ensureDurableDirectory(parent, boundary) + try { + await link(source, 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 + if (await digestFile(target) !== sha256) { + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + } + await chmod(target, 0o400) + const stop = resolve(root) + for (let level = parent; level !== stop; level = dirname(level)) { + await syncDirectory(level) + /* v8 ignore next -- filesystem-root guard: targets sit below root, so the walk reaches `stop` first. */ + if (dirname(level) === level) break + } + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } +} + +interface StagedImmutableObject extends StreamedImmutableObject { + readonly path: string + readonly boundary: string +} + +async function stageImmutableObject( + root: string, + data: AsyncIterable | Iterable, + signal?: AbortSignal, +): Promise { const staging = join(root, 'tmp') // Establish DSH_HOME itself against the filesystem root once per process. // Every process performs that proof independently, so observing a directory // another process created can never be mistaken for durable publication. const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) - await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(normalized) + const hash = createHash('sha256') + let bytes = 0 + for await (const chunk of data) { + signal?.throwIfAborted() + await handle.writeFile(chunk) + hash.update(chunk) + bytes += chunk.byteLength + } + signal?.throwIfAborted() await handle.sync() + signal?.throwIfAborted() await handle.close() handle = undefined - 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') - } - // Windows shares the read-only attribute across hard links and refuses to - // unlink either name once it is set, so discard the staging name first. - await unlink(temporary) - // The target remains the sole link for a new object; this also restores - // read-only mode when the deduplication path observes an existing object. - await chmod(target, 0o400) - // Persist the target entry and close a concurrent bucket-creation window - // before the reference can reach a session checkpoint. The dedup path - // repeats both syncs because it may observe another writer's link before - // that writer reaches its own durability boundary. - await syncDirectory(bucket) - await syncDirectory(join(root, 'objects')) + return { path: temporary, boundary, sha256: hash.digest('hex'), bytes } } catch (error) { - /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ + /* v8 ignore next -- A descriptor remains open only when write, sync, or close 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 }) + await removeTemporary(temporary) + if (error instanceof AttachmentError || signal?.aborted === true) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - return prepared.ref +} + +async function publishStagedObject( + root: string, + target: string, + staged: StagedImmutableObject, +): Promise { + const parent = dirname(target) + try { + await ensureDurableDirectory(parent, staged.boundary) + try { + await link(staged.path, 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 + if (await digestFile(target) !== staged.sha256) { + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + } + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(staged.path) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. + await chmod(target, 0o400) + // Persist the target entry and close every concurrent parent-creation + // window before the reference can reach a session checkpoint. The dedup + // path repeats these syncs because it may observe another writer's link + // before that writer reaches its own durability boundary. + const stop = resolve(root) + for (let level = parent; level !== stop; level = dirname(level)) { + await syncDirectory(level) + /* v8 ignore next -- filesystem-root guard: targets sit below root, so the walk reaches `stop` first. */ + if (dirname(level) === level) break + } + } catch (error) { + await removeTemporary(staged.path) + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } +} + +async function digestFile(path: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path) as AsyncIterable) hash.update(chunk) + return hash.digest('hex') +} + +async function removeTemporary(path: string): Promise { + await unlink(path).catch( + /* v8 ignore next -- Cleanup can observe a staging name already removed after successful linking. */ + (cleanupError: unknown) => { + /* v8 ignore next -- Any cleanup failure except an absent staging name must remain visible. */ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }, + ) } /** diff --git a/packages/attachment/attachment-local/tests/file-store.spec.ts b/packages/attachment/attachment-local/tests/file-store.spec.ts new file mode 100644 index 0000000000..dde6ec2d06 --- /dev/null +++ b/packages/attachment/attachment-local/tests/file-store.spec.ts @@ -0,0 +1,278 @@ +import { createHash } from 'node:crypto' +import { chmod, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { + fileLeafName, readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from '../src/file-store.ts' +import { publishImmutableAlias } from '../src/store.ts' + +const roots: string[] = [] + +async function makeRoot(): Promise { + const root = join(await mkdtemp(join(tmpdir(), 'dsh-file-store-')), 'attachments', 'v1') + roots.push(root) + return root +} + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(join(root, '..', '..'), { recursive: true, force: true, maxRetries: 3 }) + } +}) + +function sha256(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex') +} + +async function readStream(stream: AsyncIterable): Promise { + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + return new Uint8Array(Buffer.concat(chunks)) +} + +describe('fileLeafName', () => { + it('keeps ordinary names and strips client paths of both separator styles', () => { + expect(fileLeafName('notes.pdf')).toBe('notes.pdf') + expect(fileLeafName('/home/user/data.csv')).toBe('data.csv') + expect(fileLeafName('C:\\Users\\me\\report.docx')).toBe('report.docx') + }) + + it('removes control characters, rewrites Windows-invalid characters, and bounds UTF-8 length', () => { + expect(fileLeafName('a\u0000b\u001f.txt')).toBe('ab.txt') + expect(fileLeafName('ac:d"e|f?g*h.txt')).toBe('a_b_c_d_e_f_g_h.txt') + expect(fileLeafName(`${'x'.repeat(300)}.bin`).length).toBe(255) + const multibyte = fileLeafName(`${'文'.repeat(100)}.txt`) + expect(Buffer.byteLength(multibyte)).toBeLessThanOrEqual(255) + expect(multibyte.endsWith('\ufffd')).toBe(false) + expect(fileLeafName(`safe-${'x'.repeat(248)}\ud83d\ude00`)).not.toMatch(/\ud83d$/u) + }) + + it('removes Windows trailing characters and protects reserved device names', () => { + expect(fileLeafName('report. ')).toBe('report') + expect(fileLeafName('CON')).toBe('_CON') + expect(fileLeafName('com1.txt')).toBe('_com1.txt') + expect(fileLeafName('con .txt')).toBe('_con .txt') + expect(fileLeafName('com10.txt')).toBe('com10.txt') + }) + + it('falls back to a stable name for absent, empty, and dot-only inputs', () => { + expect(fileLeafName(undefined)).toBe('file') + expect(fileLeafName('')).toBe('file') + expect(fileLeafName(' ')).toBe('file') + expect(fileLeafName('.')).toBe('file') + expect(fileLeafName('..')).toBe('file') + }) +}) + +describe('saveFileVerbatim', () => { + it('stores the exact bytes read-only at a digest-and-name path', async () => { + const root = await makeRoot() + const data = Uint8Array.from([0, 1, 2, 250, 251, 252]) + const ref = await saveFileVerbatim(root, { data, name: 'blob.bin' }) + expect(ref).toEqual({ + attachmentId: AttachmentId(`sha256:${sha256(data)}`), + name: 'blob.bin', + bytes: data.byteLength, + }) + const path = storedFilePath(root, ref) + expect(path.endsWith(join(sha256(data), 'blob.bin'))).toBe(true) + expect(new Uint8Array(await readFile(path))).toEqual(data) + if (process.platform !== 'win32') { + expect((await stat(path)).mode & 0o777).toBe(0o400) + } + }) + + it('accepts a zero-byte file', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { data: new Uint8Array(0), name: 'empty.txt' }) + expect(ref.bytes).toBe(0) + expect((await readFile(storedFilePath(root, ref))).byteLength).toBe(0) + }) + + it('stores sanitized Windows-reserved and multibyte names', async () => { + const root = await makeRoot() + const reserved = await saveFileVerbatim(root, { data: new Uint8Array(0), name: 'NUL.txt' }) + const multibyte = await saveFileVerbatim(root, { data: Uint8Array.of(1), name: '文'.repeat(100) }) + expect(reserved.name).toBe('_NUL.txt') + expect(Buffer.byteLength(multibyte.name)).toBeLessThanOrEqual(255) + await expect(readFile(storedFilePath(root, reserved))).resolves.toHaveLength(0) + await expect(readFile(storedFilePath(root, multibyte))).resolves.toEqual(Buffer.from([1])) + }) + + it('deduplicates identical bytes and stores distinct names beside one digest', async () => { + const root = await makeRoot() + const data = Uint8Array.from([7, 7, 7]) + const first = await saveFileVerbatim(root, { data, name: 'a.txt' }) + const again = await saveFileVerbatim(root, { data, name: 'a.txt' }) + expect(again).toEqual(first) + const renamed = await saveFileVerbatim(root, { data, name: 'b.txt' }) + expect(renamed.attachmentId).toBe(first.attachmentId) + expect((await stat(storedFilePath(root, first))).ino) + .toBe((await stat(storedFilePath(root, renamed))).ino) + const digestDir = join(root, 'files', sha256(data).slice(0, 2), sha256(data)) + expect((await readdir(digestDir)).sort()).toEqual(['a.txt', 'b.txt']) + }) + + it('refuses a stored object whose bytes no longer match the digest', async () => { + const root = await makeRoot() + const data = Uint8Array.from([1, 2, 3]) + const ref = await saveFileVerbatim(root, { data, name: 'c.txt' }) + const path = storedFilePath(root, ref) + await chmod(path, 0o600) + await writeFile(path, Uint8Array.from([9, 9, 9])) + await expect(saveFileVerbatim(root, { data, name: 'c.txt' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('rejects a conflicting display-name alias and wraps alias publication failures', async () => { + const root = await makeRoot() + const data = Uint8Array.of(1, 2, 3) + const ref = await saveFileVerbatim(root, { data, name: 'alias.bin' }) + const alias = storedFilePath(root, ref) + await unlink(alias) + await writeFile(alias, Uint8Array.of(9, 9, 9)) + await expect(saveFileVerbatim(root, { data, name: 'alias.bin' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + + await expect(publishImmutableAlias( + root, + join(root, 'missing-object'), + join(root, 'files', 'ff', 'missing', 'alias.bin'), + 'f'.repeat(64), + )).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + }) +}) + +describe('saveFileStreamVerbatim', () => { + it('stores ordered chunks without materializing one aggregate byte array', async () => { + const root = await makeRoot() + const ref = await saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(0, 1) + yield Uint8Array.of(2, 250) + yield Uint8Array.of(251, 252) + })(), + name: 'large.bin', + }) + const expected = Uint8Array.of(0, 1, 2, 250, 251, 252) + expect(ref).toEqual({ + attachmentId: AttachmentId(`sha256:${sha256(expected)}`), + name: 'large.bin', + bytes: expected.byteLength, + }) + expect(new Uint8Array(await readFile(storedFilePath(root, ref)))).toEqual(expected) + }) + + it('removes its staging file when cancellation interrupts the source', async () => { + const root = await makeRoot() + const abort = new AbortController() + const reason = new Error('upload cancelled') + await expect(saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + abort.abort(reason) + yield Uint8Array.of(3, 4) + })(), + signal: abort.signal, + name: 'cancelled.bin', + })).rejects.toBe(reason) + expect(await readdir(join(root, 'tmp'))).toEqual([]) + }) + + it('wraps source failures and removes the staging file', async () => { + const root = await makeRoot() + await expect(saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + throw new Error('source failed') + })(), + name: 'failed.bin', + })).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + expect(await readdir(join(root, 'tmp'))).toEqual([]) + }) +}) + +describe('readFileStreamVerbatim', () => { + it('returns exact bounded chunks and accepts an empty file', async () => { + const root = await makeRoot() + const data = Uint8Array.from({ length: (1 << 16) + 3 }, (_, index) => index % 251) + const ref = await saveFileVerbatim(root, { data, name: 'large.bin' }) + await expect(readStream(readFileStreamVerbatim(root, ref))).resolves.toEqual(data) + const empty = await saveFileVerbatim(root, { data: new Uint8Array(), name: 'empty.bin' }) + await expect(readStream(readFileStreamVerbatim(root, empty))).resolves.toEqual(new Uint8Array()) + }) + + it('rejects invalid, missing, and unreadable references with storage codes', async () => { + const root = await makeRoot() + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + name: 'missing.bin', + bytes: 1, + } + await expect(readStream(readFileStreamVerbatim(root, { ...ref, name: '../escape' }))) + .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) + await expect(readStream(readFileStreamVerbatim(root, ref))) + .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + + const saved = await saveFileVerbatim(root, { data: Uint8Array.of(1), name: 'unreadable.bin' }) + const path = storedFilePath(root, saved) + await unlink(path) + await mkdir(path) + await expect(readStream(readFileStreamVerbatim(root, saved))) + .rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' }) + }) + + it('detects changed bytes and recorded lengths', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { data: Uint8Array.of(1, 2, 3), name: 'data.bin' }) + const path = storedFilePath(root, ref) + await chmod(path, 0o600) + await writeFile(path, Uint8Array.of(3, 2, 1)) + await expect(readStream(readFileStreamVerbatim(root, ref))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + await writeFile(path, Uint8Array.of(1, 2, 3)) + await expect(readStream(readFileStreamVerbatim(root, { ...ref, bytes: 4 }))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('preserves caller cancellation before and during a read', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { + data: Uint8Array.from({ length: 1 << 17 }, () => 7), + name: 'cancel.bin', + }) + const before = new AbortController() + const beforeReason = new Error('cancelled before read') + before.abort(beforeReason) + await expect(readStream(readFileStreamVerbatim(root, ref, before.signal))).rejects.toBe(beforeReason) + + const during = new AbortController() + const stream = readFileStreamVerbatim(root, ref, during.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ done: false }) + const duringReason = new Error('cancelled during read') + during.abort(duringReason) + await expect(stream.next()).rejects.toBe(duringReason) + }) +}) + +describe('storedFilePath', () => { + it('rejects malformed digests and unsanitized names before deriving a path', () => { + const good: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + name: 'ok.txt', + bytes: 1, + } + expect(() => storedFilePath('/root', { ...good, attachmentId: AttachmentId('sha256:short') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(() => storedFilePath('/root', { ...good, name: '../escape.txt' })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(() => storedFilePath('/root', { ...good, name: 'nested/name.txt' })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(storedFilePath('/root', good).endsWith(join('a'.repeat(64), 'ok.txt'))).toBe(true) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 4d9d9f350b..5711c6d35d 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -87,6 +87,21 @@ describe('local attachment service', () => { await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) expect(request).not.toHaveProperty('access') + + const fileData = Uint8Array.of(0, 1, 2, 255) + const fileRef = await service.saveFile({ data: fileData, name: 'notes.bin' }) + const filePath = service.fileHostPath(fileRef) + expect(filePath).toContain(join('files', String(fileRef.attachmentId).slice(7, 9))) + await expect(readFile(filePath)).resolves.toEqual(Buffer.from(fileData)) + + const streamRef = await service.saveFileStream({ + data: (async function* (): AsyncIterable { yield fileData })(), + name: 'stream.bin', + }) + await expect(readFile(service.fileHostPath(streamRef))).resolves.toEqual(Buffer.from(fileData)) + const streamed: Uint8Array[] = [] + for await (const chunk of service.readFileStream(streamRef)) streamed.push(chunk) + expect(Buffer.concat(streamed)).toEqual(Buffer.from(fileData)) } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 3ff58eb2da..82c2b9f3ba 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, parse, resolve } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' @@ -8,7 +8,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { NormalizationPolicy } from '../src/normalization.ts' -import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' +import { + commitPreparedImageFile, + prepareImageFile, + publishImmutableObject, + readImageFile, + saveImageFile, +} from '../src/store.ts' const fsControl = vi.hoisted(() => ({ readSignals: [] as AbortSignal[], @@ -89,12 +95,13 @@ describe('local attachment store', () => { // Later directory creation can then stop at that process-proven boundary. expect(fsControl.syncedDirectories).toEqual([ ...parentChainToRoot(base), - // bucket chain: every parent entry between the bucket and the boundary. - objects, + // Staging precedes publication because the streamed digest selects the + // target bucket only after every byte has been written. storageRoot, join(storageRoot, '..'), base, - // staging chain re-walks the shared ancestors after creating tmp. + // bucket chain: every parent entry between the bucket and the boundary. + objects, storageRoot, join(storageRoot, '..'), base, @@ -141,6 +148,14 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it('rejects publication when the supplied digest does not match the staged bytes', async () => { + const storageRoot = await root() + const target = join(storageRoot, 'objects', '00', 'mismatch') + await expect(publishImmutableObject(storageRoot, target, Uint8Array.of(1), '0'.repeat(64))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + expect(await readdir(join(storageRoot, 'tmp'))).toEqual([]) + }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { const storageRoot = await root() diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts index 155b3644d8..a3f5f16c8c 100644 --- a/packages/attachment/attachment/src/admission.ts +++ b/packages/attachment/attachment/src/admission.ts @@ -5,21 +5,30 @@ import { AttachmentError } from './error.ts' import type { AttachmentStore } from './index.ts' import type { AdmittedPromptContentPart, + EncodedFileAttachment, EncodedImageAttachment, + FileAttachmentRef, ImageAttachmentRef, PromptContentPart, SaveImageAttachment, } from './types.ts' /** Decode one upload payload while rejecting non-canonical base64 forms. */ -function decodeBase64(data: string): Uint8Array { +function decodeCanonicalBase64(data: string, empty: 'reject' | 'accept', code: 'INVALID_IMAGE_BASE64' | 'INVALID_FILE_BASE64'): Uint8Array { const decoded = Buffer.from(data, 'base64') - if (data.length === 0 || decoded.toString('base64') !== data) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + if ((data.length === 0 && empty === 'reject') || decoded.toString('base64') !== data) { + throw new AttachmentError( + code === 'INVALID_IMAGE_BASE64' ? 'Image upload is not canonical base64.' : 'File upload is not canonical base64.', + code, + ) } return new Uint8Array(decoded) } +function decodeBase64(data: string): Uint8Array { + return decodeCanonicalBase64(data, 'reject', 'INVALID_IMAGE_BASE64') +} + /** Store input for one decoded upload. */ function saveInput(image: EncodedImageAttachment): SaveImageAttachment { return { @@ -46,6 +55,26 @@ export async function admitEncodedImages( return attachments.saveImages(images.map(saveInput)) } +/** + * Admit one wire file upload: enforce canonical base64 (an empty file is a + * valid zero-byte payload), then delegate verbatim commit to + * {@link AttachmentStore.saveFile}. The shared entry for every RPC endpoint + * accepting browser file uploads. + * @param attachments - the deployment attachment store. + * @param file - base64-encoded upload and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError on a non-canonical payload or a storage failure. + */ +export async function admitEncodedFile( + attachments: AttachmentStore, + file: EncodedFileAttachment, +): Promise { + return attachments.saveFile({ + data: decodeCanonicalBase64(file.data, 'accept', 'INVALID_FILE_BASE64'), + ...file.name === undefined ? {} : { name: file.name }, + }) +} + /** * Admit one browser prompt and replace each uploaded image with its durable reference. * Text-only prompts do not access the attachment store. diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index c19229872b..7a1588b55e 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -18,12 +18,14 @@ export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number] /** Stable attachment failure codes used for protocol error routing. */ export type AttachmentErrorCode = | ImageAdmissionErrorCode + | 'INVALID_FILE_BASE64' | 'INVALID_ATTACHMENT_REF' | 'ATTACHMENT_CORRUPT' | 'ATTACHMENT_WRITE_FAILED' | 'ATTACHMENT_NOT_FOUND' | 'ATTACHMENT_READ_FAILED' | 'ATTACHMENT_PROJECTION_UNSUPPORTED' + | 'ATTACHMENT_FILES_UNSUPPORTED' /** Runtime membership for structurally compatible errors crossing package boundaries. */ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 9dcddeae65..8ae640c947 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -3,10 +3,13 @@ import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentError } from './error.ts' import type { + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' @@ -14,18 +17,22 @@ import type { export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' -export { admitEncodedImages, admitPromptContent } from './admission.ts' +export { admitEncodedFile, admitEncodedImages, admitPromptContent } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, + EncodedFileAttachment, EncodedImageAttachment, + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, ImageMediaType, PromptContentPart, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' @@ -121,6 +128,68 @@ export abstract class AttachmentStore extends Service { return undefined } + /** + * Durably commit one file byte-for-byte before its owning session event is + * appended. Files carry no admission limits: any byte content and length is + * accepted, and the stored object is the exact submitted bytes. Backends + * without verbatim file storage keep this default rejection. + * @param input - exact bytes and optional display name. + * @returns the durable content-addressed file reference. + */ + saveFile(input: SaveFileAttachment): Promise { + void input + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot store verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ + saveFileStream(input: SaveFileStreamAttachment): Promise { + void input + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot stream verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ + async *readFileStream( + ref: FileAttachmentRef, + signal?: AbortSignal, + ): AsyncIterable { + signal?.throwIfAborted() + void ref + await Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot read verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ + fileHostPath(ref: FileAttachmentRef): string | undefined { + void ref + return undefined + } + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 7a55c6a68f..03fb45c761 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -31,6 +31,45 @@ export interface ImageAttachmentRef { } } +/** + * Durable, serializable reference to one verbatim stored file. Files are + * stored byte-for-byte with no normalization; `attachmentId` is the sha256 + * digest of exactly those bytes. + */ +export interface FileAttachmentRef { + /** Opaque content-addressed storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Sanitized display filename, also the stored object's leaf name. */ + name: string + /** Exact byte length. */ + bytes: number +} + +/** Base64-encoded file upload accompanying one wire request. */ +export interface EncodedFileAttachment { + /** Canonical base64 encoding of the file bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} + +/** Request to durably commit one file verbatim. */ +export interface SaveFileAttachment { + data: Uint8Array + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + +/** Request to durably commit one file from bounded byte chunks. */ +export interface SaveFileStreamAttachment { + /** Exact file bytes in order; providers must not retain the complete sequence in memory. */ + data: AsyncIterable + /** Optional cancellation for source reads and storage writes. */ + signal?: AbortSignal + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + /** Deployment-resolved limits used by upload admission and request buffering. */ export interface ImageAttachmentLimits { maxImageBytes: number diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index ba24d8fa20..60eadd421f 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' +import { admitEncodedFile, admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes @@ -65,6 +65,44 @@ describe('admitEncodedImages', () => { }) }) +describe('admitEncodedFile', () => { + /** Delegation double: records the exact saveFile input and answers a fixed ref. */ + function fileStoreOf() { + const store = { + saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => Promise.resolve({ + attachmentId: 'file-1' as never, + name: input.name ?? 'file', + bytes: input.data.byteLength, + })), + } + return { store: store as unknown as AttachmentStore, mocks: store } + } + + it('decodes canonical base64 and delegates verbatim commit to saveFile', async () => { + const { store, mocks } = fileStoreOf() + const ref = await admitEncodedFile(store, { data: 'AAAA', name: 'blob.bin' }) + expect(mocks.saveFile).toHaveBeenCalledTimes(1) + const input = mocks.saveFile.mock.calls[0]?.[0] as { data: Uint8Array; name?: string } + expect([input.name, input.data.byteLength]).toEqual(['blob.bin', 3]) + expect(ref.bytes).toBe(3) + }) + + it('accepts an empty payload as a zero-byte file and omits an absent name', async () => { + const { store, mocks } = fileStoreOf() + const ref = await admitEncodedFile(store, { data: '' }) + const input = mocks.saveFile.mock.calls[0]?.[0] as object + expect('name' in input).toBe(false) + expect(ref.bytes).toBe(0) + }) + + it('rejects non-canonical base64 without touching the store', async () => { + const { store, mocks } = fileStoreOf() + await expect(admitEncodedFile(store, { data: 'not base64!!' })) + .rejects.toMatchObject({ code: 'INVALID_FILE_BASE64' }) + expect(mocks.saveFile).not.toHaveBeenCalled() + }) +}) + describe('admitPromptContent', () => { it('converts text-only prompts without touching the attachment store', async () => { const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 8675d45404..fcccd60b6e 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -147,10 +147,33 @@ describe('AttachmentStore.readImageRequest', () => { expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) - it('exposes no provider-owned host path by default', async () => { + it('rejects generic-file storage and exposes no provider-owned host path by default', async () => { const store = new RecordingStore(new Context()) const ref = await store.saveImage(image(1)) expect(store.imageHostPath(ref)).toBeUndefined() + await expect(store.saveFile({ data: Uint8Array.of(1), name: 'notes.txt' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + await expect(store.saveFileStream({ + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + name: 'notes.txt', + })).rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + const fileRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name: 'notes.txt', + bytes: 1, + } + expect(store.fileHostPath(fileRef)).toBeUndefined() + const read = async (signal?: AbortSignal): Promise => { + for await (const chunk of store.readFileStream(fileRef, signal)) { + void chunk + throw new Error('unsupported store yielded a chunk') + } + } + await expect(read()).rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + const controller = new AbortController() + const reason = new Error('cancel unsupported file read') + controller.abort(reason) + await expect(read(controller.signal)).rejects.toBe(reason) }) }) diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 5880f82c98..8ec0c14d35 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,7 +2,9 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, FileAttachmentRef, ImageAttachmentRef, ImageMediaType, RequestImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { assertNever } from '@deepseek-ai/dsh-util-values' /** Execution-world path that model tools can use to read one normalized attachment. */ @@ -126,6 +128,78 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean { || (block.type === 'tool-result' && contentHasImage(block.content))) } +/** + * True when typed model content contains a file block, walking nested + * tool-result content on the same recursion every file policy shares. + * @param content - typed model content blocks. + * @returns whether any nested block is a file. + */ +export function contentHasFile(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'file' + || (block.type === 'tool-result' && contentHasFile(block.content))) +} + +/** + * Stable model-facing handle for one durable file reference: the address of + * the verbatim stored copy and the instruction to read it on demand. This is + * the only representation a provider ever receives for a file. + * @param ref - durable verbatim file reference. + * @param readonlyPath - execution-world path of the stored copy, when resolvable. + * @returns deterministic handle text naming the file, its size, and its address. + */ +export function fileHandleText(ref: FileAttachmentRef, readonlyPath: string | undefined): string { + const digest = String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 8) + const identity = `File ${quoted(ref.name)} (${ref.bytes} bytes, sha256:${digest})` + if (readonlyPath === undefined) { + return `[${identity} was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]` + } + return `[${identity}: verbatim read-only copy saved at ${quoted(readonlyPath)}. Read that path with your file tools when its contents are needed; copy it to a writable location before modifying it. When delegating file work, include this saved path in the delegation prompt; only subagents sharing this execution environment can read it.]` +} + +/** Replace every file occurrence, including nested tool results, with handle text. */ +function replaceFilesWithHandles( + blocks: readonly ContentBlock[], + resolvePath: (ref: FileAttachmentRef) => string | undefined, +): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'file') { + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: fileHandleText(block.attachment, resolvePath(block.attachment)) }) + continue + } + if (block.type === 'tool-result') { + const content = replaceFilesWithHandles(block.content, resolvePath) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Project durable file history into deterministic handle text for every model + * route. Unlike images, no provider receives file blocks natively, so this + * projection is unconditional in request assembly. + * @param messages - complete request history. + * @param resolvePath - resolve one reference's current execution-world read path. + * @returns the original list without files, otherwise shallow message copies with handle text. + */ +export function projectFilesToText( + messages: readonly Message[], + resolvePath: (ref: FileAttachmentRef) => string | undefined, +): readonly Message[] { + if (!messages.some(message => contentHasFile(message.content))) return messages + return messages.map((message) => { + const content = replaceFilesWithHandles(message.content, resolvePath) + return content === message.content ? message : { ...message, content } + }) +} + /** Base64 length of raw image bytes, including padding. */ function base64Length(bytes: number): number { return Math.ceil(bytes / 3) * 4 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index cdecc4c96e..23f0228ca9 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -32,7 +32,10 @@ import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config. import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' import { normalizeApiKey } from './api-key.ts' -import { contentHasImage, projectImagesForTextModel } from './content.ts' +import { + contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel, +} from './content.ts' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' export * from './attribution.ts' export * from './brand.ts' @@ -660,6 +663,16 @@ export class LlmRuntime extends TypertRemoteService { return this.adapters.get(provider)?.adapter.imageRequestPricing(provider, model) } + /** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ + fileRequestText(ref: FileAttachmentRef): string { + return fileHandleText(ref, this.fileReadPath(ref)) + } + /** Detach typed adapter-owned modality metadata. */ private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { return modalities === undefined ? undefined : [...modalities] @@ -956,6 +969,26 @@ export class LlmRuntime extends TypertRemoteService { return Object.isFrozen(options) ? deepFreeze(filtered) : filtered } + /** + * Resolve the current execution-world read path of one durable file + * reference through the mounted attachment and filesystem providers. + */ + private fileReadPath(ref: FileAttachmentRef): string | undefined { + let hostPath: string | undefined + try { + hostPath = this.ctx.get('attachments')?.fileHostPath(ref) + } catch { + // A malformed durable reference degrades this occurrence to the no-path + // handle instead of failing every later request over the same log. + return undefined + } + if (hostPath === undefined) return undefined + // Structural face: dsh-llm cannot depend on the filesystem package, and + // only this one mapping method is consumed. + const fs = this.ctx.get('fs') as { processPathFromHostPath(hostPath: string): string | undefined } | undefined + return fs?.processPathFromHostPath(hostPath) + } + /** * Final adapter boundary. Adapter selection, dispatch, iterator construction, * and iteration failures become one terminal failure chunk. Middleware and @@ -993,13 +1026,21 @@ export class LlmRuntime extends TypertRemoteService { : Object.isFrozen(options) ? deepFreeze({ ...options, ...resolvedConfig }) : { ...options, ...resolvedConfig } - const projectedOptions = modelInfo.inputModalities !== undefined + // Files are never dispatched natively: every route receives handle text. + let projectedMessages: readonly Message[] = resolvedOptions.messages + if (projectedMessages.some(message => contentHasFile(message.content))) { + projectedMessages = projectFilesToText(projectedMessages, ref => this.fileReadPath(ref)) + } + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image') - && resolvedOptions.messages.some(message => contentHasImage(message.content)) - ? Object.isFrozen(resolvedOptions) - ? deepFreeze({ ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] }) - : { ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] } - : resolvedOptions + && projectedMessages.some(message => contentHasImage(message.content))) { + projectedMessages = projectImagesForTextModel(projectedMessages) + } + const projectedOptions = projectedMessages === resolvedOptions.messages + ? resolvedOptions + : Object.isFrozen(resolvedOptions) + ? deepFreeze({ ...resolvedOptions, messages: projectedMessages as Message[] }) + : { ...resolvedOptions, messages: projectedMessages as Message[] } const stream = dispatch(this.forAdapter(projectedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4303538892..c480f57b89 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ToolCallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' import type { Message } from './message.ts' @@ -74,6 +74,19 @@ export interface ImageBlock { attachment: ImageAttachmentRef } +/** + * A durable verbatim file reference, valid in user content. Files never reach + * a provider natively: request assembly projects every occurrence to + * deterministic handle text (name, byte size, and the read-only saved path), + * so adapters and providers see text in its place while the durable log keeps + * the structured reference for presentation and authorization. + */ +export interface FileBlock { + type: 'file' + /** Immutable verbatim bytes and display metadata owned by the attachment service. */ + attachment: FileAttachmentRef +} + /** A tool invocation requested by the model. */ export interface ToolCallBlock { type: 'tool-call' @@ -100,6 +113,7 @@ export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 748e20f412..af114958b1 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -3,7 +3,10 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { ToolCallId, + contentHasFile, createUserMessage, + fileHandleText, + projectFilesToText, offloadedImageText, offloadedImagePrefixCount, offloadRequestImagesWithPolicy, @@ -360,3 +363,80 @@ describe('projectImagesForTextModel', () => { ]) }) }) + +describe('file projection', () => { + function fileBlock(name: string): Extract { + return { + type: 'file', + attachment: { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name, + bytes: 42, + }, + } + } + + it('detects file blocks at the top level and inside nested tool results', () => { + expect(contentHasFile([{ type: 'text', text: 'x' }])).toBe(false) + expect(contentHasFile([fileBlock('a.txt')])).toBe(true) + expect(contentHasFile([{ + type: 'tool-result', + toolCallId: ToolCallId('call-1'), + content: [{ + type: 'tool-result', + toolCallId: ToolCallId('call-2'), + content: [fileBlock('deep.txt')], + }], + }])).toBe(true) + }) + + it('renders the handle with the read path or the explicit no-path fallback', () => { + const withPath = fileHandleText(fileBlock('notes.pdf').attachment, '/home/.dsh/attachments/v1/files/ab/x/notes.pdf') + expect(withPath).toContain('"notes.pdf"') + expect(withPath).toContain('42 bytes') + expect(withPath).toContain('sha256:abababab') + expect(withPath).toContain('"/home/.dsh/attachments/v1/files/ab/x/notes.pdf"') + expect(withPath).toContain('include this saved path in the delegation prompt') + expect(withPath).toContain('only subagents sharing this execution environment can read it') + const withoutPath = fileHandleText(fileBlock('notes.pdf').attachment, undefined) + expect(withoutPath).toContain('current execution environment cannot access a readable path') + expect(withoutPath).toContain('do not claim to have read it') + }) + + it('replaces every file occurrence with handle text and keeps file-free history identical', () => { + const plain = [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source })] + expect(projectFilesToText(plain, () => '/p')).toBe(plain) + const unchangedTool = { + type: 'tool-result' as const, + toolCallId: ToolCallId('call-plain'), + content: [{ type: 'text' as const, text: 'unchanged result' }], + } + const messages = [plain[0]!, createUserMessage({ + content: [ + fileBlock('top.csv'), + { type: 'text', text: 'keep' }, + unchangedTool, + { + type: 'tool-result', + toolCallId: ToolCallId('call-3'), + content: [fileBlock('nested.csv')], + }, + ], + source, + })] + const projected = projectFilesToText(messages, ref => `/copies/${ref.name}`) + expect(projected).not.toBe(messages) + expect(projected[0]).toBe(messages[0]) + const content = projected[1]!.content + expect(content[0]).toEqual({ type: 'text', text: fileHandleText(fileBlock('top.csv').attachment, '/copies/top.csv') }) + expect(content[1]).toEqual({ type: 'text', text: 'keep' }) + expect(content[2]).toBe(messages[1]!.content[2]) + const nested = content[3] as Extract + expect(nested.content[0]).toEqual({ + type: 'text', + text: fileHandleText(fileBlock('nested.csv').attachment, '/copies/nested.csv'), + }) + // The durable message is untouched: projection returns shallow copies. + expect(messages[1]!.content[0]!.type).toBe('file') + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 47309daea1..2bfcd0f486 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -205,6 +205,73 @@ describe('LlmRuntime', () => { expect(adapter.lastOptions?.messages[0]).toBe(message) }) + it('projects file blocks through every host-path availability outcome', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name: 'notes.txt', + bytes: 3, + } + const cases = [ + { + name: 'native tools under read-only permission receive the mapped read path', + attachments: { fileHostPath: () => '/host/notes.txt' }, + fs: { processPathFromHostPath: () => '/sandbox/notes.txt' }, + expected: '"/sandbox/notes.txt"', + }, + { + name: 'Code Mode under workspace-write permission receives the same mapped read path', + attachments: { fileHostPath: () => '/host/notes.txt' }, + fs: { processPathFromHostPath: () => '/code-sandbox/notes.txt' }, + expected: '"/code-sandbox/notes.txt"', + }, + { + name: 'missing attachment service', + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'provider without a host path', + attachments: { fileHostPath: () => undefined }, + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'invalid durable reference', + attachments: { fileHostPath: () => { throw new Error('invalid ref') } }, + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'missing filesystem mapping', + attachments: { fileHostPath: () => '/host/notes.txt' }, + expected: 'current execution environment cannot access a readable path', + }, + ] + + for (const fixture of cases) { + const ctx = new Context() + if (fixture.attachments !== undefined) ctx.provide('attachments', fixture.attachments as never) + if (fixture.fs !== undefined) ctx.provide('fs', fixture.fs as never) + await ctx.plugin(LlmRuntime) + const adapter = new RecordingAdapter(SCRIPT) + ctx.llm.registerAdapter(['test-provider'], adapter) + + await collect(ctx.llm.stream({ + provider: 'test-provider', + model: 'test-model', + messages: [createUserMessage({ + content: [{ type: 'file', attachment }], + source: { kind: 'user' }, + })], + })) + + const projected = adapter.lastOptions?.messages[0]?.content[0] + expect(projected, fixture.name).toMatchObject({ type: 'text' }) + if (projected?.type !== 'text') throw new Error(`expected projected text for ${fixture.name}`) + expect(projected.text, fixture.name).toContain(fixture.expected) + if (fixture.fs !== undefined) { + expect(projected.text, fixture.name).toContain('include this saved path in the delegation prompt') + } + } + }) + it('captures provider-owned retry policy at registration and defaults omission', async () => { const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') const adapter = new class extends ScriptedAdapter { diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index d97d9d3fc2..c3c3029219 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -7,7 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { LlmImageRequestPricing, LlmRuntime, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' @@ -136,7 +136,8 @@ export class TokenMeter extends Service { ? state.header : canonicalHeader(requestHeader) const pricing = this._routeImagePricing(header) - const surface = priceSurface(state.surface, pricing) + const fileText = this._fileRequestText() + const surface = priceSurface(state.surface, pricing, fileText) const anchor = state.anchor let baseline: TokenMeasurementBaseline @@ -145,7 +146,7 @@ export class TokenMeter extends Service { // Matching headers share one route, so the anchored snapshot reprices // under the same pricing as the current surface and the signed delta // compares like with like. - const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing).surfaceTokens + const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText).surfaceTokens + anchor.assistantTokens const estimatedAnchorTokens = estimateHeader(header) + anchorSurfaceTokens const usage = anchor.usage @@ -183,6 +184,14 @@ export class TokenMeter extends Service { return this.ctx.get('llm')?.imageRequestPricing(config.provider, config.model) } + /** Resolve request-time file projection when an LLM service is mounted. */ + private _fileRequestText(): ( + (ref: Parameters[0]) => string + ) | undefined { + const llm = this.ctx.get('llm') + return llm === undefined ? undefined : ref => llm.fileRequestText(ref) + } + /** * Heuristically price one model-visible message (instance face of the pure * `estimateMessage` export from `estimate.ts`). diff --git a/packages/llm/token-meter/src/route-pricing.ts b/packages/llm/token-meter/src/route-pricing.ts index 402db18ac0..03a1c519c3 100644 --- a/packages/llm/token-meter/src/route-pricing.ts +++ b/packages/llm/token-meter/src/route-pricing.ts @@ -1,18 +1,17 @@ /** - * Route-aware surface pricing: projects the fold's fixed-heuristic nodes onto - * the routed model's request, replacing every image occurrence's structural - * price with the route's declared visual tokens plus the model-visible text it - * actually sends. Without declared pricing every node keeps its fixed - * heuristic price, so provider-neutral behavior is unchanged. + * Request-projected surface pricing: replaces attachment-block heuristics with + * the image and file representations sent to the routed model. * * @module @deepseek-ai/dsh-token-meter/route-pricing */ -import type { LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' import { estimateContent } from './estimate.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import type { TokenSurfaceNode } from './types.ts' +type FileAttachmentRef = Extract['attachment'] + /** One surface priced for a request route: public nodes plus their total. */ export interface PricedSurface { /** Positional nodes carrying both the route price and the fixed-heuristic price. */ @@ -22,9 +21,10 @@ export interface PricedSurface { } /** - * Price one ordered surface under a route's request-image pricing. + * Price one ordered surface under its model-request attachment projection. * @param nodes - the fold's current or snapshotted surface, in model-visible order. * @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic. + * @param fileText - exact file handle projection used by the mounted LLM service. * @returns detached public nodes and their route-priced total. * @throws when the pricing answers a different occurrence count than it was * asked — misalignment would silently misprice nodes, so it must fail loud. @@ -32,9 +32,11 @@ export interface PricedSurface { export function priceSurface( nodes: readonly MeterSurfaceNode[], pricing: LlmImageRequestPricing | undefined, + fileText?: (ref: FileAttachmentRef) => string, ): PricedSurface { const images = pricing === undefined ? [] : nodes.flatMap(node => node.images) - if (pricing === undefined || images.length === 0) { + const hasFiles = fileText !== undefined && nodes.some(node => node.files.length > 0) + if ((pricing === undefined || images.length === 0) && !hasFiles) { let surfaceTokens = 0 const publicNodes = nodes.map((node) => { surfaceTokens += node.heuristicTokens @@ -42,8 +44,8 @@ export function priceSurface( }) return { nodes: publicNodes, surfaceTokens } } - const prices = pricing.priceImages(images) - if (prices.length !== images.length) { + const prices = pricing === undefined ? [] : pricing.priceImages(images) + if (pricing !== undefined && prices.length !== images.length) { throw new Error( `token meter: route image pricing answered ${prices.length} prices for ${images.length} occurrences`, ) @@ -52,8 +54,14 @@ export function priceSurface( let surfaceTokens = 0 const publicNodes = nodes.map((node) => { let tokens = node.heuristicTokens - if (node.images.length > 0) { - tokens = node.imageFreeTokens + if (fileText !== undefined && node.files.length > 0) { + tokens -= node.fileStructuralTokens + for (const file of node.files) { + tokens += estimateContent([{ type: 'text', text: fileText(file) }]) + } + } + if (pricing !== undefined && node.images.length > 0) { + tokens -= node.imageStructuralTokens for (let occurrence = 0; occurrence < node.images.length; occurrence += 1) { // oxlint-disable-next-line typescript/no-non-null-assertion -- length equality is asserted above const price = prices[cursor]! diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index 66824a2974..f947adfc35 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -10,8 +10,8 @@ * fallible step read-only and {@link commitSurfaceTokens} mutates in place, * so a throw leaves the caller's state untouched and the same malformed * event fails identically on every retry. - * Nodes also carry their durable image occurrences and image-free heuristic - * price, so `measure()` can reprice image content for the routed model. + * Nodes also carry durable attachment occurrences and their structural prices, + * so `measure()` can price the request representation sent to the model. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ @@ -22,16 +22,22 @@ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { estimateMessage, estimateStructuralBlock } from './estimate.ts' +type FileAttachmentRef = Extract['attachment'] + /** One priced surface node with the image occurrences route pricing replaces. */ export interface MeterSurfaceNode { /** Durable sequence number of the surface event. */ readonly seq: number /** Fixed-heuristic price of the node's exact message. */ readonly heuristicTokens: number - /** Fixed-heuristic price with every image occurrence's structural price removed. */ - readonly imageFreeTokens: number + /** Structural JSON price replaced when the routed request projects images. */ + readonly imageStructuralTokens: number + /** Structural JSON price replaced when request assembly projects files to text. */ + readonly fileStructuralTokens: number /** Durable image occurrences in message order; empty for image-free nodes. */ readonly images: readonly ImageAttachmentRef[] + /** Durable file occurrences in message order; empty for file-free nodes. */ + readonly files: readonly FileAttachmentRef[] } /** One validated surface transition that has not mutated the priced surface yet. */ @@ -46,31 +52,53 @@ export interface SurfaceTokenPlan { readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } -/** Collect image occurrences recursively and total their structural prices. */ -function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentRef[]): number { - let structuralTokens = 0 +/** Collect projected attachment occurrences and their structural prices. */ +function collectProjectedAttachments( + blocks: readonly ContentBlock[], + images: ImageAttachmentRef[], + files: FileAttachmentRef[], +): { readonly imageTokens: number; readonly fileTokens: number } { + let imageTokens = 0 + let fileTokens = 0 for (const block of blocks) { if (block.type === 'image') { images.push(block.attachment) - structuralTokens += estimateStructuralBlock(block) + imageTokens += estimateStructuralBlock(block) + } else if (block.type === 'file') { + files.push(block.attachment) + fileTokens += estimateStructuralBlock(block) } else if (block.type === 'tool-result') { - structuralTokens += collectImages(block.content, images) + const nested = collectProjectedAttachments(block.content, images, files) + imageTokens += nested.imageTokens + fileTokens += nested.fileTokens } } - return structuralTokens + return { imageTokens, fileTokens } } /** Build one priced node from a surface event's derived message. */ function analyzeNode(seq: number, message: Message | null): MeterSurfaceNode { - if (message === null) return { seq, heuristicTokens: 0, imageFreeTokens: 0, images: [] } + if (message === null) { + return { + seq, + heuristicTokens: 0, + imageStructuralTokens: 0, + fileStructuralTokens: 0, + images: [], + files: [], + } + } const heuristicTokens = estimateMessage(message) const images: ImageAttachmentRef[] = [] - const imageStructuralTokens = collectImages(message.content, images) + const files: FileAttachmentRef[] = [] + const structural = collectProjectedAttachments(message.content, images, files) return { seq, heuristicTokens, - imageFreeTokens: heuristicTokens - imageStructuralTokens, + imageStructuralTokens: structural.imageTokens, + fileStructuralTokens: structural.fileTokens, images, + files, } } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index 37ef87ce04..65fb15a2f7 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { LlmRuntime, LlmAdapter, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + LlmRuntime, LlmAdapter, createMessage, createUserMessage, projectFilesToText, +} from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmImageRequestPricing, Message, StreamChunk, TokenUsage, UserMessage } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -53,6 +55,14 @@ function imageMessage(name: string, text = 'look at this'): UserMessage { }) } +function fileRef(name: string): FileAttachmentRef { + return { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name, + bytes: 2_447_000_000, + } +} + function header(model: string): EpochHeader { return canonicalHeader({ config: { provider: 'mock', model } }) } @@ -65,6 +75,12 @@ interface Harness { async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise { const ctx = new Context() new SessionProjectionRegistry(ctx) + ctx.provide('attachments', { + fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`, + } as never) + ctx.provide('fs', { + processPathFromHostPath: (path: string) => path.replace('/host/', '/sandbox/'), + } as never) const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) const meter = new TokenMeter(ctx) @@ -96,7 +112,23 @@ function appendSuccessfulCall(session: Session, value: EpochHeader, usage?: Toke session.append('step/end', { turn: 1, step: 1 }) } -describe('route-aware image pricing', () => { +describe('request projection pricing', () => { + it('prices file blocks as the exact handle text dispatched to the provider', async () => { + const { meter, session } = await harness(() => undefined) + const ref = fileRef('archive.zip') + const message = createUserMessage({ + content: [{ type: 'file', attachment: ref }], + source: { kind: 'user' }, + }) + session.append('user/message', message, { surfaceOp: 'append' }) + + const measurement = meter.measure(session) + const projected = projectFilesToText([message], file => `/sandbox/${file.name}`)[0] + if (projected === undefined) throw new Error('missing projected file message') + expect(measurement.nodes[0]?.tokens).toBe(estimateMessage(projected)) + expect(measurement.nodes[0]?.tokens).toBeGreaterThan(estimateMessage(message)) + }) + it('prices a first multimodal request estimate with the routed visual tokens', async () => { const { meter, session } = await harness(() => fixedPricing) const message = imageMessage('photo') diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 2f7d1fbd11..d89e036193 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -1,12 +1,12 @@ /** * Host-side session-log download: streams one ZIP archive whose files are the - * sessions' stored artifact text verbatim plus every referenced media object. + * sessions' stored artifact text verbatim plus every referenced attachment. * The root artifact sits under its original base name (`session.jsonl`); each * subagent descendant under `subagents//`; each image referenced * by any included log under `media/.` (content-addressed, - * so one archive never duplicates a shared image). No manifest is written — - * every file is byte-identical to the backend's durable artifact or attachment - * store and self-describing through its own header line or media type. Before + * so one archive never duplicates a shared image); each file under + * `files///`. No manifest is written. Every entry is + * byte-identical to the backend's durable artifact or attachment store. Before * each live session's artifact read, the SessionStore flush barrier makes the * current in-memory log durable; cold sessions need no barrier. Request abort * and response-consumer cancellation share one producer signal and terminate @@ -21,7 +21,9 @@ import { Zip, ZipDeflate } from 'fflate' import type { Context } from '@deepseek-ai/cordis' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' @@ -84,10 +86,11 @@ export async function flushLiveSessionLog( signal?.throwIfAborted() } -/** One exported file: a stored artifact text or one referenced media object. */ +/** One exported artifact or referenced attachment object. */ export type SessionLogZipEntry = | { readonly path: string; readonly content: string } | { readonly path: string; readonly data: Uint8Array } + | { readonly path: string; readonly chunks: AsyncIterable } /** Zip extension for each accepted raster media type. */ const MEDIA_TYPE_EXTENSIONS: Record = { @@ -108,13 +111,26 @@ function mediaEntryPath(ref: ImageAttachmentRef): string { return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` } +/** Archive path that preserves one stored file reference's digest and name. */ +function fileEntryPath(ref: FileAttachmentRef): string { + const digest = String(ref.attachmentId).replace(/^sha256:/u, '') + const name = ref.name.replace(/[\\/\u0000-\u001f\u007f]/gu, '_') + const safeName = name === '.' || name === '..' || name === '' ? 'file' : name + return `files/${digest.slice(0, 2)}/${digest}/${safeName}` +} + /** - * Collect every image reference inside one content array, descending into + * Collect every attachment reference inside one content array, descending into * nested tool results the way the live attachment route does. * @param content - an event content array (or nested tool-result content). - * @param refs - the dedupe map being filled (keyed by attachment id). + * @param images - image dedupe map keyed by attachment id. + * @param files - file dedupe map keyed by attachment id and stored name. */ -function collectImageRefs(content: unknown, refs: Map): void { +function collectAttachmentRefs( + content: unknown, + images: Map, + files: Map, +): void { if (!Array.isArray(content)) return const pending: unknown[] = [] for (const item of content) pending.push(item) @@ -124,7 +140,11 @@ function collectImageRefs(content: unknown, refs: Map): void { +function collectEventAttachmentRefs( + event: unknown, + images: Map, + files: Map, +): void { const data = (event as { data?: unknown }).data if (typeof data !== 'object' || data === null) return const carrier = data as { @@ -148,23 +173,27 @@ function collectEventImageRefs(event: unknown, refs: Map chunk?: { type?: unknown; block?: unknown } } - collectImageRefs(carrier.content, refs) - if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) + collectAttachmentRefs(carrier.content, images, files) + if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files) if (carrier.inserted !== undefined) { - for (const message of carrier.inserted) collectImageRefs(message.content, refs) + for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files) } - if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs) + if (carrier.chunk?.type === 'block-end') collectAttachmentRefs([carrier.chunk.block], images, files) } /** - * Collect the distinct media references one stored artifact text names. - * Lines that fail to parse cannot reference media and are skipped (the + * Collect the distinct attachment references one stored artifact text names. + * Lines that fail to parse cannot reference attachments and are skipped (the * artifact text itself is exported verbatim regardless). * @param content - the stored artifact text. - * @returns the dedupe map keyed by attachment id. + * @returns image and file dedupe maps. */ -function imageRefsInArtifact(content: string): Map { - const refs = new Map() +function attachmentRefsInArtifact(content: string): { + readonly images: Map + readonly files: Map +} { + const images = new Map() + const files = new Map() for (const line of content.split('\n')) { if (line === '') continue let event: unknown @@ -173,9 +202,9 @@ function imageRefsInArtifact(content: string): Map { } catch { continue } - collectEventImageRefs(event, refs) + collectEventAttachmentRefs(event, images, files) } - return refs + return { images, files } } /** @@ -204,10 +233,10 @@ export function sessionLogZipFilename(sessionId: string): string { * Yield the export entries in zip order: the preloaded root artifact first, * then every subagent descendant in lineage order (each flushed when live, * read from the persistence backend right before it is yielded, and dropped - * after the consumer moves on), then every distinct media object referenced by any of - * the included logs (read and verified from the attachment store, one archive - * entry per attachment id). The host holds at most one descendant's artifact - * text and one media object at a time beyond the root. + * after the consumer moves on), then every distinct attachment referenced by + * the included logs. Images are read and verified as bounded stored objects; + * generic files remain streamed through the ZIP writer. The host holds at most + * one descendant artifact, one image, and one file chunk beyond the root. * @param deps - the mounted export services (the caller answered 500 before this runs). * @param root - the already-read root artifact (read by the caller so the * missing-session path can answer cleanly before streaming starts). @@ -224,10 +253,13 @@ export async function* sessionLogZipEntries( signal?: AbortSignal, ): AsyncGenerator { const media = new Map() - const rememberMedia = (content: string): void => { - for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) + const files = new Map() + const rememberAttachments = (content: string): void => { + const refs = attachmentRefsInArtifact(content) + for (const [id, ref] of refs.images) media.set(id, ref) + for (const [id, ref] of refs.files) files.set(id, ref) } - rememberMedia(root.content) + rememberAttachments(root.content) yield { path: root.filename, content: root.content } if (includeDescendants) { const seen = new Set([sessionId]) @@ -245,7 +277,7 @@ export async function* sessionLogZipEntries( if (raw === undefined) { throw new Error(`subagent "${id}" has no stored log artifact`) } - rememberMedia(raw.content) + rememberAttachments(raw.content) yield { path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, content: raw.content, @@ -263,6 +295,13 @@ export async function* sessionLogZipEntries( signal?.throwIfAborted() yield { path: mediaEntryPath(ref), data: stored.data } } + for (const ref of files.values()) { + signal?.throwIfAborted() + yield { + path: fileEntryPath(ref), + chunks: deps.attachments.readFileStream(ref, signal), + } + } } /** How many code units of artifact text one zip push carries (bounded encode memory). */ @@ -334,6 +373,25 @@ async function pushBinaryChunks( } while (offset < data.byteLength) } +/** Push one streamed file entry without retaining its complete byte sequence. */ +async function pushStreamChunks( + deflate: ZipDeflate, + chunks: AsyncIterable, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + for await (const chunk of chunks) { + signal.throwIfAborted() + if (chunk.byteLength === 0) continue + deflate.push(chunk, false) + await capacity.wait(controller, signal) + } + signal.throwIfAborted() + deflate.push(new Uint8Array(), true) + await capacity.wait(controller, signal) +} + /** * Push one artifact's text into a deflate stream in bounded chunks, never * splitting a surrogate pair across a chunk boundary (a lone high surrogate @@ -427,8 +485,10 @@ export function streamSessionLogZip( archive.add(deflate) if ('content' in entry) { await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) - } else { + } else if ('data' in entry) { await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) + } else { + await pushStreamChunks(deflate, entry.chunks, controller, capacity, producerSignal) } } archive.end() diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 88c4cc6ccb..91a97faff9 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -55,6 +55,7 @@ interface SessionLogConnection { register(route: { readonly path: string readonly methods: readonly ('GET' | 'HEAD')[] + readonly requestBody: 'buffered' readonly fetch: (request: Request) => Promise }): () => Promise } @@ -81,6 +82,7 @@ export function apply(ctx: Context, config: Config = {}): void { connectionOf(ctx).fetch.register({ path: SESSION_LOG_EXPORT_PATH, methods: ['GET', 'HEAD'], + requestBody: 'buffered', fetch: async (request) => { const response = await sessionLogExportResponse( ctx, diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index c41846039a..af1ea9e428 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -9,7 +9,7 @@ import { randomBytes } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' @@ -55,6 +55,11 @@ function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` } +/** A user/message event line carrying one generic-file reference. */ +function fileEventLine(id: string, name = 'notes.txt', bytes = 5): string { + return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"file","attachment":{"attachmentId":"${id}","name":"${name}","bytes":${bytes}}}]}}` +} + async function buildApi( artifacts: Record, descendants: SessionLineageNode[] = [], @@ -62,6 +67,7 @@ async function buildApi( query?: boolean persistence?: boolean | 'throw' | 'unsupported' attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) + readFileStream?: (ref: FileAttachmentRef, signal?: AbortSignal) => AsyncIterable sessions?: { get(id: SessionId): { readonly id: SessionId } | undefined flush(session: { readonly id: SessionId }): Promise @@ -110,6 +116,9 @@ async function buildApi( validateImage: async () => {}, saveImage: async () => { throw new Error('export never saves images') }, readImage, + readFileStream: services.readFileStream ?? (async function* () { + throw new Error('fixture has no files') + }), } as never) } if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) @@ -630,6 +639,52 @@ describe('session.export download endpoint', () => { expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) }) + it('streams generic files under their content-addressed archive paths', async () => { + const digest = 'a'.repeat(64) + const id = `sha256:${digest}` + const fallbackDigest = 'c'.repeat(64) + const fallbackId = `sha256:${fallbackDigest}` + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + fileEventLine(id, 'notes.txt', 5), + fileEventLine(fallbackId, '.', 5), + ].join('\n') + '\n') + const reads: Array<{ ref: FileAttachmentRef; signal: AbortSignal | undefined }> = [] + const api = await buildApi({ 'session-root': root }, [], { + readFileStream: (ref, signal) => (async function* (): AsyncIterable { + reads.push({ ref, signal }) + yield new Uint8Array() + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4, 5) + })(), + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files[`files/aa/${digest}/notes.txt`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5)) + expect(files[`files/cc/${fallbackDigest}/file`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5)) + expect(reads).toHaveLength(2) + expect(reads[0]?.ref).toMatchObject({ attachmentId: id, name: 'notes.txt', bytes: 5 }) + expect(reads[0]?.signal).toBeInstanceOf(AbortSignal) + }) + + it('fails the whole export when a referenced file stream fails', async () => { + const digest = 'b'.repeat(64) + const id = `sha256:${digest}` + const root = artifact('session-root', undefined, fileEventLine(id)) + const api = await buildApi({ 'session-root': root }, [], { + readFileStream: () => (async function* (): AsyncIterable { + yield Uint8Array.of(1) + throw new Error('file bytes missing') + })(), + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing') + }) + it('collects media referenced from nested tool results', async () => { const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' const root = artifact('session-root', undefined, [ From 8a0ff3aff82ab0ee5b2b9ef87b75abcd262fa776 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:38:21 +0800 Subject: [PATCH 02/37] feat(session-controller): admit files across command flows --- packages/api/session-controller/package.json | 2 + .../api/session-controller/src/commands.ts | 226 +++++++++++++++++- .../src/file-upload-http.ts | 116 +++++++++ .../src/file-upload-path.ts | 2 + packages/api/session-controller/src/index.ts | 22 ++ packages/api/session-controller/src/types.ts | 28 ++- .../api/session-controller/tsconfig.host.json | 2 + packages/goal/command-goal/src/index.ts | 10 +- packages/interaction/commands/src/index.ts | 115 +++++++-- packages/interaction/commands/src/types.ts | 12 +- packages/plan/plan-mode/src/index.ts | 4 +- tsconfig.host.json | 1 + 12 files changed, 496 insertions(+), 44 deletions(-) create mode 100644 packages/api/session-controller/src/file-upload-http.ts create mode 100644 packages/api/session-controller/src/file-upload-path.ts diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index fc1de04248..89e96dc0f8 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -119,6 +120,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 701aedcc36..f9bc529184 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,12 +4,13 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, admitEncodedFile, admitPromptContent } from '@deepseek-ai/dsh-attachment' +import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' @@ -35,6 +36,7 @@ import type { SessionCreateValue, SessionForkRequest, SessionForkValue, + FileUploadReceiptId, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -43,6 +45,9 @@ import type { SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, + SessionUploadFileRequest, + SessionUploadFileValue, + SessionRequestId, } from './types.ts' interface SessionReadState { @@ -51,8 +56,21 @@ interface SessionReadState { readonly events: readonly SessionEvent[] } +interface StagedFileUpload { + readonly file: FileAttachmentRef + /** Prompt that accepted this receipt; absent until successful admission. */ + requestId?: SessionRequestId +} + /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { + /** + * Staged file uploads awaiting a prompt, keyed by Session. Entries are the + * prompt-time authority for file references: a prompt may only cite a file + * previously uploaded for the same Session in this process. + */ + private readonly stagedFiles = new Map>() + /** * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. * @param agents - sole owner of create, resume, and Session-local model selection. @@ -62,7 +80,115 @@ export class SessionCommandController { private readonly ctx: Context, private readonly agents: ApiSessionAgentController, private readonly defaultCwd: string, - ) {} + ) { + ctx.inject(['commands'], (commandCtx) => { + const resolve: CommandFileReceiptResolver = (agent, receiptId) => + this.resolveStagedFile(agent.id, receiptId as FileUploadReceiptId) + commandCtx.effect( + () => commandCtx.commands.registerFileReceiptResolver(resolve), + 'session-controller: command file receipt resolver', + ) + }) + } + + /** + * Persist one browser file upload verbatim and stage it for later prompts. + * @param request - Session identity, base64 payload, and optional display name. + * @returns an opaque per-upload receipt and the durable file reference. + */ + async uploadFile(request: SessionUploadFileRequest): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.commitFileUpload(agent, async () => admitEncodedFile(this.ctx.attachments, { + data: request.data, + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Persist raw upload chunks without collecting the complete file in memory. + * @param request - Session identity, ordered exact bytes, cancellation, and optional display name. + * @returns an opaque per-upload receipt and the durable file reference. + */ + async uploadFileStream(request: { + readonly sessionId: SessionId + readonly data: AsyncIterable + readonly signal?: AbortSignal + readonly name?: string + }): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.commitFileUpload(agent, async () => this.ctx.attachments.saveFileStream({ + data: request.data, + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + private async commitFileUpload( + agent: Agent, + save: () => Promise, + ): Promise { + let file: FileAttachmentRef + try { + file = await save() + } catch (error) { + if (error instanceof AttachmentError) { + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) + } + throw new RemoteError( + 'gateway/internal', + `failed to store file upload: ${String(error)}`, + {}, + { cause: error }, + ) + } + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed before its file upload completed`, + { sessionId: agent.id }, + ) + } + let staged = this.stagedFiles.get(agent.id) + if (staged === undefined) { + staged = new Map() + this.stagedFiles.set(agent.id, staged) + } + const receiptId = randomUUID() as FileUploadReceiptId + staged.set(receiptId, { file }) + return { receiptId, file } + } + + /** + * Resolve one staged upload for the same Session without exposing the receipt table. + * @param sessionId - receiving Session identity. + * @param receiptId - Host-minted upload receipt. + * @returns the durable file reference, or `undefined` when the receipt is absent or belongs elsewhere. + */ + resolveStagedFile(sessionId: SessionId, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { + return this.stagedFiles.get(sessionId)?.get(receiptId)?.file + } + + /** + * Retire file receipts only after their accepted prompt becomes observable. + * @param sessionId - Session whose log emitted the prompt. + * @param requestId - browser prompt identity echoed by the event. + */ + retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void { + const staged = this.stagedFiles.get(sessionId) + if (staged === undefined) return + for (const [receiptId, upload] of staged) { + if (upload.requestId === requestId) staged.delete(receiptId) + } + if (staged.size === 0) this.stagedFiles.delete(sessionId) + } + + /** + * Drop one Session's staged uploads (the stored objects remain durable). + * @param sessionId - Session leaving the live registry. + */ + releaseStagedFiles(sessionId: SessionId): void { + this.stagedFiles.delete(sessionId) + } /** * Create or idempotently adopt one ordinary Session. @@ -292,6 +418,7 @@ export class SessionCommandController { ) } const agent = await this.resolveAgent(request.sessionId) + if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current if (!routeServed(this.ctx, selection.provider)) { throw new RemoteError( @@ -319,10 +446,42 @@ export class SessionCommandController { ) } } - const content = await admitPromptContent(this.ctx.attachments, request.content) - const message: UserMessage = createUserMessage({ content, source }) - if (request.mode === 'steer') agent.steer(message) - else agent.followup(message) + const staged = this.stagedFiles.get(request.sessionId) + const durable = await durablePromptContent( + this.ctx, + request.content, + receiptId => staged?.get(receiptId)?.file, + ) + const message: UserMessage = createUserMessage({ content: durable.content, source }) + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed during prompt admission`, + { sessionId: agent.id }, + ) + } + const bound = durable.receiptIds.map((receiptId) => { + const upload = staged?.get(receiptId) + if (upload === undefined) { + throw new RemoteError( + 'session/attachment-invalid', + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' }, + ) + } + return { upload, previous: upload.requestId } + }) + for (const { upload } of bound) upload.requestId = request.requestId + try { + if (request.mode === 'steer') agent.steer(message) + else agent.followup(message) + } catch (error) { + for (const { upload, previous } of bound) { + if (previous === undefined) delete upload.requestId + else upload.requestId = previous + } + throw error + } } catch (error) { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { @@ -416,6 +575,12 @@ export class SessionCommandController { })) } else { agent.inbox.remove(request.itemId) + if (request.action.kind === 'remove') { + const source = message.source + if (source.kind === 'user' && 'rpcId' in source) { + this.retireObservedPrompt(request.sessionId, source.rpcId) + } + } if (request.action.kind === 'steer') agent.steer(message) } return { accepted: true } @@ -492,6 +657,51 @@ export class SessionCommandController { } } +async function durablePromptContent( + ctx: Context, + content: readonly SessionPromptRequest['content'][number][], + stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, +): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> { + const files = new Map() + for (const part of content) { + if (part.type !== 'file' || files.has(part.receiptId)) continue + const file = stagedFile(part.receiptId) + if (file === undefined) { + throw new RemoteError( + 'session/attachment-invalid', + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' }, + ) + } + files.set(part.receiptId, file) + } + type NonFilePart = Exclude + const admitted = await admitPromptContent( + ctx.attachments, + content.filter((part): part is NonFilePart => part.type !== 'file'), + ) + let next = 0 + const durable = content.map((part) => { + if (part.type === 'file') { + return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef } + } + return admitted[next++] as ContentBlock + }) + return { content: durable, receiptIds: [...files.keys()] } +} + +function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { + const matches = (message: UserMessage): boolean => { + const source = message.source + return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId + } + if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true + return agent.session.snapshotEvents().some((event) => { + if (event.type !== 'user/message') return false + const source = event.data.source + return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId + }) +} function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, diff --git a/packages/api/session-controller/src/file-upload-http.ts b/packages/api/session-controller/src/file-upload-http.ts new file mode 100644 index 0000000000..7a4a210e65 --- /dev/null +++ b/packages/api/session-controller/src/file-upload-http.ts @@ -0,0 +1,116 @@ +/** Raw Fetch file intake used by the browser background-upload carrier. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { SESSION_FILE_UPLOAD_PATH } from './file-upload-path.ts' +import type { SessionUploadFileValue } from './types.ts' +import type { SessionCommandController } from './commands.ts' + +interface FileUploadConnection { + readonly fetch: { + register(route: { + readonly path: string + readonly methods: readonly ['POST'] + readonly requestBody: 'streaming' + readonly fetch: (request: Request) => Promise + }): () => Promise + } +} + +type FileUploadResult = + | { readonly ok: true; readonly value: SessionUploadFileValue } + | { + readonly ok: false + readonly error: { readonly code: string; readonly message: string; readonly details: object } + } + +/** + * Install the authenticated raw-byte route on Connection's shared Fetch registry. + * @param ctx - Host context that provides Connection. + * @param commands - Session command owner that validates and stages stored bytes. + */ +export function registerSessionFileUploadHttp(ctx: Context, commands: SessionCommandController): void { + ctx.inject(['connection'], (connectionCtx) => { + const connection = connectionCtx.get('connection') as FileUploadConnection + connection.fetch.register({ + path: SESSION_FILE_UPLOAD_PATH, + methods: ['POST'], + requestBody: 'streaming', + fetch: request => handleSessionFileUploadHttp(commands, request), + }) + }) +} + +/** + * Handle one authenticated raw-byte upload after the physical carrier applies + * its trust policy. + * @param commands - Session command owner that validates and stages stored bytes. + * @param request - Fetch request carrying the raw file body. + * @returns JSON receipt or a precise validation response. + */ +export async function handleSessionFileUploadHttp( + commands: SessionCommandController, + request: Request, +): Promise { + if (request.method !== 'POST') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }) + } + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/octet-stream') { + return new Response('content type must be application/octet-stream', { status: 415 }) + } + const url = new URL(request.url) + const sessionId = url.searchParams.get('sessionId') + if (sessionId === null || sessionId === '') { + return new Response('sessionId is required', { status: 400 }) + } + const name = url.searchParams.get('name') ?? undefined + let result: FileUploadResult + try { + result = { + ok: true, + value: await commands.uploadFileStream({ + sessionId: SessionId(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + } + } catch (error) { + const failure = remoteErrorOf(error) + result = { + ok: false, + error: failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: 'gateway/internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function* requestBodyChunks( + body: ReadableStream | null, +): AsyncIterable { + if (body === null) return + const reader = body.getReader() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) return + yield chunk.value + } + } finally { + reader.releaseLock() + } +} diff --git a/packages/api/session-controller/src/file-upload-path.ts b/packages/api/session-controller/src/file-upload-path.ts new file mode 100644 index 0000000000..8c5c1f0b8e --- /dev/null +++ b/packages/api/session-controller/src/file-upload-path.ts @@ -0,0 +1,2 @@ +/** Authenticated raw-byte upload route shared by Host and Client faces. */ +export const SESSION_FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 4a103dbd22..272fc199f0 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -16,6 +16,7 @@ import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' +import { registerSessionFileUploadHttp } from './file-upload-http.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' @@ -49,6 +50,8 @@ import type { SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, + SessionUploadFileRequest, + SessionUploadFileValue, } from './types.ts' export type * from './types.ts' @@ -116,6 +119,7 @@ export class SessionController extends TypertRemoteService { installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) + registerSessionFileUploadHttp(ctx, this.commands) this.controlState = new SessionControlController(ctx) // Registered before history so reverse-order teardown closes every // follower before waiting for already-admitted promotions. @@ -137,6 +141,7 @@ export class SessionController extends TypertRemoteService { ctx.emit('api-session/added', this.listState.summaryFor(session)) }) ctx.on('session/disposed', (session) => { + this.commands.releaseStagedFiles(session.id) ctx.emit('api-session/removed', session.id) }) ctx.on('agent/status', ({ agent, status }) => { @@ -146,6 +151,10 @@ export class SessionController extends TypertRemoteService { ctx.emit('api-session/error', agent.id, errorChain(error)) }) ctx.on('session/event', (session, event) => { + if (event.type === 'user/message' && event.data.source.kind === 'user' + && 'rpcId' in event.data.source) { + this.commands.retireObservedPrompt(session.id, event.data.source.rpcId) + } if (event.type === 'request/header') { const agent = ctx.agents.get(session.id) if (agent?.session === session) this.agents.consumeSelection( @@ -334,6 +343,19 @@ export class SessionController extends TypertRemoteService { return this.commands.attachment(request) } + /** + * Persist one encoded file upload verbatim and stage it for a later prompt + * on the same Session. + * @param request - Session identity, base64 payload, and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns an opaque per-upload receipt and the durable file reference. + */ + @Remote('uploadFile') + uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commands.uploadFile(request) + } + /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b25fcf2c..6a8109178e 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, + AttachmentIdType, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -68,7 +68,11 @@ export interface SessionProjectionBaseline { export type SessionProjectionValues = Partial & Readonly> -/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +/** + * Browser-submitted prompt content; the Host promotes image bytes to durable + * references. File parts carry the opaque receipt returned by a preceding + * `uploadFile` call on the same Session. + */ export type PromptContentPart = | { readonly type: 'text'; readonly text: string } | { @@ -77,6 +81,7 @@ export type PromptContentPart = readonly data: string readonly name?: string } + | { readonly type: 'file'; readonly receiptId: FileUploadReceiptId } /** Complete model selection for one Session. */ export interface ModelSelection { @@ -313,6 +318,22 @@ export interface SessionPromptValue { readonly accepted: true } +/** One base64 file upload staged for a later prompt on the same Session. */ +export interface SessionUploadFileRequest { + readonly sessionId: SessionId + /** Canonical base64 encoding of the exact file bytes. */ + readonly data: string + /** Optional display name; the Host sanitizes it into the stored leaf name. */ + readonly name?: string +} + +/** Durable receipt for one staged file upload. */ +export interface SessionUploadFileValue { + /** Per-upload authority consumed by a later prompt on the same Session. */ + readonly receiptId: FileUploadReceiptId + readonly file: FileAttachmentRef +} + /** Durable image read request. */ export interface SessionAttachmentRequest { readonly sessionId: SessionId @@ -361,6 +382,9 @@ export interface SessionOpenWorkspacePathValue { /** Client-minted prompt identity used to reconcile optimistic and durable messages. */ export type SessionRequestId = Branded<'session-request-id'> +/** Host-minted authority for one staged file upload on one Session. */ +export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> + declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { /** Browser prompt correlation and optional Host-validated time zone. */ diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 854579f157..a5c52b3274 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -14,6 +14,8 @@ "src/commands.ts", "src/control.ts", "src/file-references.ts", + "src/file-upload-path.ts", + "src/file-upload-http.ts", "src/history.ts", "src/list.ts", "src/model-selection-projection.ts", diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index e1b6e79eed..b9e64ee7f2 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -108,15 +108,15 @@ function missingGoal(action: string): CommandResult { } /** - * Submit the invocation's admitted composer images as one model-visible user - * message ahead of the goal's next round. The images precede a fixed text + * Submit the invocation's admitted composer attachments as one model-visible user + * message ahead of the goal's next round. The attachments precede a fixed text * block naming their role, so a later goal round reads them from ordinary * session history without the goal domain storing attachment state. */ function submitObjectiveAttachments(invocation: CommandInvocation): void { if (invocation.attachments.length === 0) return invocation.agent.followup(createUserMessage({ - content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }], + content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }], source: { kind: 'user' }, })) } @@ -127,7 +127,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') { return { kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', } } try { @@ -190,7 +190,7 @@ export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]', images: true }, + input: { hint: '[|clear|edit |pause|resume]', attachments: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index b0b3a0dd45..029a1ce7b2 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Agent } from '@deepseek-ai/dsh-agent' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' -import type { ImageBlock } from '@deepseek-ai/dsh-llm' +import type { EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' @@ -19,6 +19,7 @@ import type { CommandExecution, CommandInputDescriptor, CommandResult, + CommandSubmitAttachment, } from './types.ts' export { CommandId } from './brand.ts' @@ -28,8 +29,11 @@ export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Shared frozen attachments value for image-free invocations. */ -const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([]) +/** Shared frozen attachments value for attachment-free invocations. */ +const NO_ATTACHMENTS: readonly (ImageBlock | FileBlock)[] = Object.freeze([]) + +/** Host resolver for Session-scoped staged file-upload receipts. */ +export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined /** Invocation passed to one registered command handler. */ export interface CommandInvocation { @@ -40,13 +44,13 @@ export interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -191,12 +195,12 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (rawInput.hint.trim().length === 0) { throw new TypeError(`command "${definition.name}" input hint must not be empty`) } - if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') { - throw new TypeError(`command "${definition.name}" input images flag must be a boolean`) + if ('attachments' in rawInput && rawInput.attachments !== undefined && typeof rawInput.attachments !== 'boolean') { + throw new TypeError(`command "${definition.name}" input attachments flag must be a boolean`) } input = Object.freeze({ hint: rawInput.hint, - ...('images' in rawInput && rawInput.images === true) ? { images: true } : {}, + ...('attachments' in rawInput && rawInput.attachments === true) ? { attachments: true } : {}, }) } const normalized = Object.freeze({ @@ -258,6 +262,8 @@ export class CommandRuntime extends TypertRemoteService { private commandSeq = 0 /** Instance token keeping minted ids unique across process restarts over one resumed log. */ private readonly instanceToken = randomUUID().slice(0, 8) + /** Optional provider installed by the Session upload owner. */ + private readonly fileReceipts: { resolver: CommandFileReceiptResolver | undefined } = { resolver: undefined } constructor(ctx: Context) { super(ctx, 'commands') @@ -277,6 +283,21 @@ export class CommandRuntime extends TypertRemoteService { ) } + /** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ + registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void { + if (this.fileReceipts.resolver !== undefined) { + throw new Error('commands: a file receipt resolver is already registered') + } + this.fileReceipts.resolver = resolver + return () => { + if (this.fileReceipts.resolver === resolver) this.fileReceipts.resolver = undefined + } + } + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -313,15 +334,17 @@ export class CommandRuntime extends TypertRemoteService { * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. @@ -330,7 +353,7 @@ export class CommandRuntime extends TypertRemoteService { async execute( agent: Agent, line: string, - images: readonly EncodedImageAttachment[], + submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise { const parsed = parseCommand(line) @@ -355,18 +378,21 @@ export class CommandRuntime extends TypertRemoteService { }) return Object.freeze({ commandId, result: Object.freeze(result) }) } - let attachments: readonly ImageBlock[] = NO_ATTACHMENTS - if (images.length > 0) { - if (command.definition.input?.images !== true) { - return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` }) + let attachments: readonly (ImageBlock | FileBlock)[] = NO_ATTACHMENTS + if (submittedAttachments.length > 0) { + if (command.definition.input?.attachments !== true) { + return settle({ kind: 'error', text: `/${parsed.name} does not accept attachments` }) } const store = this.ctx.get('attachments') if (store === undefined) { - return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` }) + return settle({ kind: 'error', text: `/${parsed.name}: attachments are unavailable because no attachment store is composed` }) } try { - const refs = await admitEncodedImages(store, images) - attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref }))) + attachments = await admitCommandAttachments( + store, + submittedAttachments, + receiptId => this.fileReceipts.resolver?.(agent, receiptId), + ) } catch (error: unknown) { if (error instanceof AttachmentError) { return settle({ kind: 'error', text: error.message }) @@ -455,4 +481,45 @@ export class CommandRuntime extends TypertRemoteService { } } +/** Admit a mixed command batch and restore its original image/file order. */ +async function admitCommandAttachments( + store: Parameters[0], + attachments: readonly CommandSubmitAttachment[], + resolveFileReceipt: (receiptId: string) => FileAttachmentRef | undefined, +): Promise { + const files = new Map() + for (const attachment of attachments) { + if (attachment.type !== 'file' || files.has(attachment.receiptId)) continue + const file = resolveFileReceipt(attachment.receiptId) + if (file === undefined) { + throw new AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND') + } + files.set(attachment.receiptId, file) + } + const images: EncodedImageAttachment[] = [] + for (const attachment of attachments) { + if (attachment.type !== 'image') continue + images.push({ + mediaType: attachment.mediaType, + data: attachment.data, + ...(attachment.name === undefined ? {} : { name: attachment.name }), + }) + } + const imageRefs = images.length === 0 ? [] : await admitEncodedImages(store, images) + let imageIndex = 0 + const blocks: Array = [] + for (const attachment of attachments) { + if (attachment.type === 'image') { + const ref = imageRefs[imageIndex] as ImageAttachmentRef + imageIndex += 1 + blocks.push(Object.freeze({ type: 'image', attachment: ref })) + continue + } + blocks.push(Object.freeze({ + type: 'file', attachment: files.get(attachment.receiptId) as FileAttachmentRef, + })) + } + return Object.freeze(blocks) +} + export default CommandRuntime diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 621a5971ba..3827c1ad17 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -8,19 +8,25 @@ */ import type { CommandId } from './brand.ts' +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' + +/** One browser-submitted command attachment: encoded image input or a staged file receipt. */ +export type CommandSubmitAttachment = + | ({ readonly type: 'image' } & EncodedImageAttachment) + | { readonly type: 'file'; readonly receiptId: string } /** Immutable metadata for a command's optional unstructured input. */ export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } /** Expected command outcome rendered directly by the dispatching UI. */ diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 1372a90f50..392f9193e7 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -225,11 +225,11 @@ export class PlanModeController extends Service { commandCtx.commands.register({ name: 'plan', description: 'Enter or leave plan mode', - input: { hint: '[off|message]', images: true }, + input: { hint: '[off|message]', attachments: true }, handler: ({ agent, rawInput, attachments }) => { const message = rawInput.trim() if (message === 'off' && attachments.length > 0) { - return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' } + return { kind: 'error', text: 'Attachments cannot accompany /plan off.' } } if (message === 'off') { switch (this.set(agent, false)) { diff --git a/tsconfig.host.json b/tsconfig.host.json index 6dfe0a9d31..9fb38215be 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -50,6 +50,7 @@ "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", + "apps/web/tests/file-upload-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/message-feedback-layout.e2e.ts", From bafa6ae11d089941d6bf7a884089c4e3b44abda7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:38:31 +0800 Subject: [PATCH 03/37] feat(conversation): stream files through submission lifecycle --- .../src/client/contract/session.ts | 32 ++- .../src/client/contract/snapshot.ts | 10 +- .../session-controller/src/client/index.ts | 6 +- .../src/client/sessions/manager.ts | 3 + .../src/client/sessions/queue-mirror.ts | 6 +- .../src/client/sessions/remotes.ts | 4 +- .../src/client/sessions/service.ts | 3 + .../src/client/sessions/session.ts | 133 +++++++++++-- .../session-controller/tsconfig.client.json | 1 + .../src/client/background-upload.ts | 184 ++++++++++++++++++ .../client/connection/src/client/fixture.ts | 16 +- .../client/connection/src/client/index.ts | 28 ++- packages/client/connection/src/http-bridge.ts | 84 ++++---- packages/client/connection/src/index.ts | 1 + packages/client/connection/src/rpc-host.ts | 13 +- packages/client/connection/src/rpc.ts | 14 +- .../client/connection/tsconfig.client.json | 1 + .../client/ui-commands/src/client/locales.ts | 4 +- .../client/ui-commands/src/client/service.ts | 36 ++-- .../ui-input-trigger/src/client/index.ts | 2 +- packages/client/ui-input-trigger/src/types.ts | 6 +- .../webworker-runtime/src/client/client.ts | 7 +- .../webworker-runtime/src/transport/frames.ts | 6 +- .../src/transport/synthetic-http.ts | 11 +- .../src/client/api-catalog.ts | 36 +++- .../src/client/slot-catalog.ts | 49 ++--- .../client-runtime/src/sessions.ts | 8 + 27 files changed, 559 insertions(+), 145 deletions(-) create mode 100644 packages/client/connection/src/client/background-upload.ts diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 71cc72e0ab..256a3f6e67 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,22 +7,25 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' -import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' +import type { FileUploadReceiptId, PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' +import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts' /** * Why a local submission echo left the snapshot: `observed` when its durable * `user/message` event or host queue occurrence arrived (with the admitted - * image references in prompt order), `failed` when the prompt was rejected, + * attachment references in prompt order), `failed` when the prompt was rejected, * threw, or was aborted before acceptance. */ export type PendingSubmissionRetirement = - | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { + readonly reason: 'observed' + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[] + } | { readonly reason: 'failed' } /** Input registering one local submission echo ahead of its prompt call. */ @@ -31,8 +34,8 @@ export interface BeginSubmissionInput { readonly mode: 'queue' | 'steer' /** Prompt text exactly as the upcoming prompt will send it. */ readonly text: string - /** Ordered image previews matching the upcoming prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] /** Settlement callback fired exactly once when the echo retires. */ readonly onRetire?: (retirement: PendingSubmissionRetirement) => void } @@ -86,6 +89,21 @@ export interface ISession { signal?: AbortSignal, requestId?: SessionRequestId, ): Promise> + /** + * Persist one browser file verbatim and stage it for a later prompt on this + * session. The returned opaque receipt is what a prompt file part cites. + * @param data - browser Blob or exact file bytes. + * @param name - optional display name; the host sanitizes the stored leaf name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background Blob uploads. + * @returns the staged-upload receipt and durable file reference, or the business error. + */ + uploadFile( + data: Blob | Uint8Array, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index ff08a2fd06..3b4f449d69 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -1,5 +1,6 @@ /** Session-owned observable state excluding Conversation target data. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' @@ -30,6 +31,11 @@ export interface PendingSubmissionImage { readonly height?: number } +/** One attachment displayed by a local submission echo, in prompt order. */ +export type PendingSubmissionAttachment = + | ({ readonly type: 'image' } & PendingSubmissionImage) + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + /** Client surface selected when a local submission begins. */ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' @@ -48,8 +54,8 @@ export interface PendingSubmission { readonly time: number /** Prompt text exactly as it will be sent (one text block). */ readonly text: string - /** Ordered image previews matching the prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] } /** History-open lifecycle of a Session event window. */ diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 4bad2bb8c2..7df2341ebb 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -59,6 +60,7 @@ export type { export type { OpenState, PendingSubmission, + PendingSubmissionAttachment, PendingSubmissionImage, PendingSubmissionPlacement, PromptError, @@ -75,6 +77,7 @@ declare module '@deepseek-ai/cordis' { /** Required Remote and Context projection services. */ export const inject = [ + 'connection', 'typert', 'remote', 'remote.commands', @@ -87,8 +90,9 @@ export const inject = [ * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { + const connection = ctx.get('connection') as ConnectionHandle const remotes = ctx.remote as unknown as SessionRemotes - const sessions = new ClientSessions(ctx, remotes) + const sessions = new ClientSessions(ctx, remotes, connection.backgroundUploads) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 47392c86f3..08a2448686 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,6 +25,7 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' +import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client' /** * List arrival lifecycle, orthogonal to the pull-activity `state` axis: @@ -149,6 +150,7 @@ export class SessionManager { private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, + private readonly backgroundUploads?: BackgroundUploadTransport, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -328,6 +330,7 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), + ...(this.backgroundUploads === undefined ? {} : { backgroundUploads: this.backgroundUploads }), }) } diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 985ad8a841..f0ed979c2b 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts' const QUEUE_PREVIEW_CHARS = 200 -// Image blocks are excluded: queue presentation renders them as thumbnails -// from `content`, so the text preview covers only what has no visual form. +// Attachment blocks are excluded: queue presentation renders them from +// `content`, so the text preview covers only what has no visual form. function previewOf(content: readonly ContentBlock[]): string { const flat = content - .filter(block => block.type !== 'image') + .filter(block => block.type !== 'image' && block.type !== 'file') .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) .join(' ').replace(/\s+/g, ' ').trim() const chars = Array.from(flat) diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index bec8709b90..c2e585514b 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes */ -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, @@ -19,7 +19,7 @@ export interface SessionCommandsRemote { execute( agentId: SessionId, line: string, - images: readonly EncodedImageAttachment[], + attachments: readonly CommandSubmitAttachment[], signal?: AbortSignal, ): Promise> } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 3e6c8bf6e0..285f7ad014 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../scope.ts' import { SessionManager } from './manager.ts' import type { SessionRemotes } from './remotes.ts' +import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { Session } from './session.ts' @@ -221,6 +222,7 @@ export class ClientSessions implements ISessions { constructor( private readonly rootCtx: Context, remote: SessionRemotes, + backgroundUploads?: BackgroundUploadTransport, ) { this.selection = createSnapshotStore( {}, @@ -230,6 +232,7 @@ export class ClientSessions implements ISessions { remote, restored.sessionId, restored.subagentAddress, + backgroundUploads, ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 6e33412cf9..d618dec7c7 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -1,8 +1,11 @@ // Sessions remain resident after creation so their open Remote sources keep running off-screen. import type { Context } from '@deepseek-ai/cordis' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { + BackgroundUploadProgress, BackgroundUploadTransport, +} from '@deepseek-ai/dsh-client-connection/client' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -15,7 +18,9 @@ import type { SessionControlFrame, SessionQueuedItem, SessionRequestId, + SessionUploadFileValue, } from '../../types.ts' +import { SESSION_FILE_UPLOAD_PATH } from '../../file-upload-path.ts' import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' @@ -28,6 +33,7 @@ import type { } from '../contract/events.ts' import { Notifier } from './notifier.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' @@ -62,6 +68,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore + /** Physical large-body carrier supplied by the active browser Connection. */ + backgroundUploads?: BackgroundUploadTransport } /** @@ -196,7 +204,7 @@ export class Session implements SessionFace { : 'transcript', time: Date.now(), text: input.text, - images: input.images, + attachments: input.attachments, }] this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) // The blank → engaging edge flips here, ahead of prompt(): the composer @@ -208,7 +216,7 @@ export class Session implements SessionFace { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - text plus browser-owned temporary image uploads. + * @param content - text, browser-owned temporary image uploads, and staged-file receipts. * @param mode - queue appends after the current turn; steer interrupts it. * @param signal - optional caller cancellation for the complete admission round-trip. * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. @@ -238,13 +246,25 @@ export class Session implements SessionFace { content, clientTimeZone, }, signal) + } else if (content.some(part => part.type === 'file')) { + result = { + ok: false, + error: new RemoteError( + 'subagent/attachment-invalid', + 'subagent continuation does not accept files', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + ), + } } else { + // The preceding branch rejects file parts before the narrower subagent + // wire type is used; this array is not filtered or reordered. + const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ requestId: randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', - content, + content: routedContent, clientTimeZone: resolvedClientTimeZone(), }, signal) result = routed.ok ? { ok: true, value: { accepted: true } } : routed @@ -271,6 +291,52 @@ export class Session implements SessionFace { return result } + /** + * Persist one browser file verbatim and stage it for a later prompt on this + * session (ordinary sessions only; subagent conversations refuse). + * @param data - exact file bytes. + * @param name - optional display name; the host sanitizes the stored leaf name. + * @returns the staged-upload receipt and durable file reference, or the business error. + */ + async uploadFile( + data: Blob | Uint8Array, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: BackgroundUploadProgress) => void, + ): Promise> { + if (this.address !== undefined) { + return { + ok: false, + error: new RemoteError( + 'subagent/attachment-invalid', + 'subagent conversations do not accept file uploads', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + ), + } + } + if (data instanceof Blob && this.options.backgroundUploads !== undefined) { + const query = new URLSearchParams({ sessionId: this.sessionId }) + if (name !== undefined) query.set('name', name) + const response = await this.options.backgroundUploads.post({ + path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`, + body: data, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return parseFileUploadResult(response.body) + } + const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) + return this.remote.session.uploadFile({ + sessionId: this.sessionId, + data: bytesToBase64(bytes), + ...(name === undefined ? {} : { name }), + }, signal) + } + /** * Resolve one image referenced by this session into browser-consumable bytes. * @param attachmentId - opaque id found in the folded session log. @@ -657,7 +723,7 @@ export class Session implements SessionFace { const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return - this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content)) } /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ @@ -665,7 +731,7 @@ export class Session implements SessionFace { if (this.submissionSettlements.size === 0) return for (const item of items) { if (item.rpcId !== undefined) { - this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content)) } } } @@ -678,7 +744,7 @@ export class Session implements SessionFace { */ private scheduleObservedRetirement( requestId: SessionRequestId, - attachments: readonly ImageAttachmentRef[], + attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[], ): void { const settlement = this.submissionSettlements.get(requestId) if (settlement === undefined || settlement.retiring) return @@ -750,21 +816,62 @@ export class Session implements SessionFace { } } +function parseFileUploadResult(body: string): RemoteResult { + const value = JSON.parse(body) as unknown + if (!isRecord(value) || typeof value.ok !== 'boolean') { + throw new TypeError('file upload transport returned an invalid result') + } + if (!value.ok) { + const error = value.error + if (!isRecord(error) || typeof error.code !== 'string' + || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('file upload transport returned an invalid failure') + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + } + } + const result = value.value + const file = isRecord(result) ? result.file : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' + || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + throw new TypeError('file upload transport returned an invalid receipt') + } + return { + ok: true, + value: { + receiptId: result.receiptId as SessionUploadFileValue['receiptId'], + file: { + attachmentId: file.attachmentId as SessionUploadFileValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, + }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ function scheduleFrame(fn: () => void): void { if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) else setTimeout(fn, 0) } -/** Image attachment references in one structurally-read content block list, in block order. */ -function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { +/** Attachment references in one structurally-read content block list, in block order. */ +function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] { if (!Array.isArray(content)) return [] - const refs: ImageAttachmentRef[] = [] + const refs: Array = [] for (const block of content) { if (typeof block !== 'object' || block === null) continue const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } - if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { - refs.push(candidate.attachment as ImageAttachmentRef) + if ((candidate.type === 'image' || candidate.type === 'file') + && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef) } } return refs diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 030ed712d1..d88247a535 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -7,6 +7,7 @@ }, "include": [ "src/client/**/*.ts", + "src/file-upload-path.ts", "src/types.ts", "src/remote-events.ts" ], diff --git a/packages/client/connection/src/client/background-upload.ts b/packages/client/connection/src/client/background-upload.ts new file mode 100644 index 0000000000..7d6b87315d --- /dev/null +++ b/packages/client/connection/src/client/background-upload.ts @@ -0,0 +1,184 @@ +/** Background browser upload transport for large opaque request bodies. */ + +import type { RpcFetch } from './rpc.ts' + +/** Monotone byte progress reported by a browser upload carrier. */ +export interface BackgroundUploadProgress { + readonly loaded: number + readonly total?: number +} + +/** One background upload request. */ +export interface BackgroundUploadRequest { + readonly path: string + readonly body: Blob + readonly headers?: Readonly> + readonly signal?: AbortSignal + readonly onProgress?: (progress: BackgroundUploadProgress) => void +} + +/** Small response returned after the background carrier has sent the body. */ +export interface BackgroundUploadResponse { + readonly status: number + readonly body: string +} + +/** Browser carrier that keeps file reads and network submission off the page thread. */ +export interface BackgroundUploadTransport { + /** + * Post one Blob without materializing its bytes on the page thread. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: BackgroundUploadRequest): Promise +} + +interface UploadWorkerStart { + readonly url: string + readonly body: Blob + readonly headers: Readonly> +} + +type UploadWorkerOutput = + | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number } + | { readonly kind: 'complete'; readonly status: number; readonly body: string } + | { readonly kind: 'error'; readonly message: string } + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: UploadWorkerOutput): void +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } + status: number + responseText: string + withCredentials: boolean + onload: ((event: ProgressEvent) => void) | null + onerror: ((event: ProgressEvent) => void) | null + open(method: string, url: string): void + setRequestHeader(name: string, value: string): void + send(body: Blob): void +} + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for request and progress messages. + * @param createXhr - XMLHttpRequest factory; injectable for unit coverage. + */ +export function backgroundUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data + const xhr = createXhr() + xhr.open('POST', request.url) + xhr.withCredentials = true + for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value) + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: 'progress', + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput) + } + xhr.onload = () => { + scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput) + } + xhr.onerror = () => { + scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput) + } + xhr.send(request.body) + } +} + +/** + * Create a background body carrier. A custom fetch already targets a Host + * Worker, while the served Web path creates a dedicated upload Worker. + * @param customFetch - worker-hosted transport hook, when present. + * @returns the selected background carrier. + */ +export function createBackgroundUploadTransport(customFetch?: RpcFetch): BackgroundUploadTransport { + return customFetch === undefined ? workerTransport() : customTransport(customFetch) +} + +function customTransport(customFetch: RpcFetch): BackgroundUploadTransport { + return { + async post(request) { + const response = await customFetch(resolveUrl(request.path), { + method: 'POST', + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.signal === undefined ? {} : { signal: request.signal }), + }) + return { status: response.status, body: await response.text() } + }, + } +} + +function workerTransport(): BackgroundUploadTransport { + return { + post(request) { + if (typeof Worker !== 'function') { + return Promise.reject(new Error('background upload requires Web Worker support')) + } + const workerUrl = URL.createObjectURL(new Blob([ + `(${backgroundUploadWorker.toString()})()`, + ], { type: 'text/javascript' })) + const worker = new Worker(workerUrl, { name: 'dsh-file-upload' }) + URL.revokeObjectURL(workerUrl) + return new Promise((resolve, reject) => { + let settled = false + const abort = (): void => { + settled = true + worker.terminate() + request.signal?.removeEventListener('abort', abort) + reject(new DOMException('The operation was aborted.', 'AbortError')) + } + const finish = (settle: () => void): void => { + if (settled) return + settled = true + request.signal?.removeEventListener('abort', abort) + worker.terminate() + settle() + } + worker.onmessage = (event: MessageEvent) => { + const output = event.data + if (output.kind === 'progress') { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }) + } else if (output.kind === 'complete') { + finish(() => { resolve({ status: output.status, body: output.body }) }) + } else { + finish(() => { reject(new Error(output.message)) }) + } + } + worker.onerror = (event) => { + finish(() => { reject(new Error(event.message || 'background upload worker failed')) }) + } + if (request.signal?.aborted === true) { + abort() + return + } + request.signal?.addEventListener('abort', abort, { once: true }) + worker.postMessage({ + url: resolveUrl(request.path).href, + body: request.body, + headers: request.headers ?? {}, + } satisfies UploadWorkerStart) + }) + }, + } +} + +function resolveUrl(path: string): URL { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + const origin = typeof pageLocation === 'object' && pageLocation !== null + && 'origin' in pageLocation && typeof pageLocation.origin === 'string' + ? pageLocation.origin + : undefined + return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index c79b8caf20..06a71610d2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2099,13 +2099,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', images: true } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', attachments: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ], } }, - execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult { + execute(id: SessionId, line: string, attachments: readonly unknown[] = []): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing // Structured split mirroring the Host parser: name + verbatim rawInput @@ -2116,17 +2116,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Mirror the Host image policy AFTER command resolution, matching the // executor's order (an unknown name answers undefined and logs no // lifecycle): the declaration rejection covers every known command - // without `input.images`, and the two producer grammar rejections cover + // without `input.attachments`, and the two producer grammar rejections cover // the declaring commands' control-only lines. The fixture stores no // bytes, so an accepted batch is acknowledged and dropped. const known = ['permission', 'goal', 'compact', 'echo', 'plan'] - if (images.length > 0 && name !== undefined && known.includes(name)) { + if (attachments.length > 0 && name !== undefined && known.includes(name)) { const rejection = name !== 'goal' && name !== 'plan' - ? `/${name} does not accept image attachments` + ? `/${name} does not accept attachments` : name === 'goal' && args.trim() === '' - ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' + ? 'Attachments only accompany a goal objective: /goal or /goal edit .' : name === 'plan' && args.trim() === 'off' - ? 'Image attachments cannot accompany /plan off.' + ? 'Attachments cannot accompany /plan off.' : undefined if (rejection !== undefined) { const commandId = `fx-cmd-${logOf(id).length}` as CommandId diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 482ef2db91..b70e40c59e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,7 +1,4 @@ -/** - * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets API Gateway own the connection loop. - */ +/** Browser wire client: Remote transport, connection generations, and background uploads. */ import type { Context } from '@deepseek-ai/cordis' import { ConnectionController, @@ -13,6 +10,10 @@ import { } from './connection.ts' import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' +import { + createBackgroundUploadTransport, + type BackgroundUploadTransport, +} from './background-upload.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -53,6 +54,12 @@ export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' +export type { + BackgroundUploadProgress, + BackgroundUploadRequest, + BackgroundUploadResponse, + BackgroundUploadTransport, +} from './background-upload.ts' /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { @@ -107,9 +114,8 @@ interface ClientTransportGlobal { } /** - * The ctx.connection service API: the API client plus a one-shot controller - * starter. API Gateway supplies generation readiness and reset callbacks; - * Connection stays independent of downstream domain state. + * The ctx.connection service API. API Gateway supplies generation readiness + * and reset callbacks; Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** @@ -124,6 +130,8 @@ export interface ConnectionHandle { readonly state: ConnectionStateSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc + /** Large-body carrier; absent for the in-page fixture transport. */ + readonly backgroundUploads?: BackgroundUploadTransport /** Reset retry progression and replace the current attempt immediately. */ reconnect(): void /** @@ -178,7 +186,7 @@ function watchBrowserNetwork(controller: ConnectionController): () => void { } /** - * Client plugin body: pick the api by page mode and provide ctx.connection. + * Client plugin body: pick physical carriers by page mode and provide ctx.connection. * @param ctx - client cordis context. */ export function apply(ctx: Context): void { @@ -187,6 +195,9 @@ export function apply(ctx: Context): void { const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const backgroundUploads = fixtureRpc === undefined + ? createBackgroundUploadTransport(transport?.fetch) + : undefined let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 @@ -241,6 +252,7 @@ export function apply(ctx: Context): void { }, }, rpc, + ...(backgroundUploads === undefined ? {} : { backgroundUploads }), reconnect() { owner?.controller.reconnect() }, diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index b404e65103..ff6042d117 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -4,6 +4,8 @@ */ import type { IncomingMessage, ServerResponse } from 'node:http' +import { Readable } from 'node:stream' +import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default * aggregate image limit (200 MiB) after base64 expansion plus envelope @@ -11,28 +13,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * each body in memory, so this cap is also the per-request resident bound. */ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 -/** Transport-independent request handler consumed by the Host HTTP bridge. */ -export interface FetchHandler { - /** - * Handle one standard Fetch request. - * @param request - request produced by the active transport bridge. - * @returns complete or streaming Fetch response. - */ - fetch(request: Request): Promise -} - /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; response bodies stream out chunk by chunk). - * @param req - incoming node:http request (fully read before dispatch). + * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. - * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch. + * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: ServerResponse, - apiHandler: FetchHandler, + apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -44,38 +36,57 @@ export async function bridge( res.on('close', () => { if (!res.writableEnded) abort.abort() }) - const declaredLength = req.headers['content-length'] - if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { - res.writeHead(413, { connection: 'close' }) - res.end() - req.destroy() - return - } - const chunks: Buffer[] = [] - let received = 0 - for await (const chunk of req) { - const buffer = chunk as Buffer - received += buffer.byteLength - if (received > maxRequestBodyBytes) { + /* v8 ignore next 2 -- node:http always sets url/method on server requests. */ + const url = new URL(req.url ?? '/', 'http://dsh.internal') + const method = req.method ?? 'GET' + const headers = Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][], + ) + const bodyMode = apiHandler.requestBodyMode({ method, url }) + let request: Request + if (bodyMode === 'buffered') { + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { res.writeHead(413, { connection: 'close' }) res.end() req.destroy() return } - chunks.push(buffer) + const chunks: Buffer[] = [] + let received = 0 + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + chunks.push(buffer) + } + request = new Request(url, { + method, + headers, + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + } else { + request = new Request(url, { + method, + headers, + body: Readable.toWeb(req) as ReadableStream, + signal: abort.signal, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) } - /* 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'), { - method: req.method ?? 'GET', - headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), - ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, - signal: abort.signal, - }) const response = await apiHandler.fetch(request) - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + const requestUnread = bodyMode === 'streaming' && !req.readableEnded + const responseHeaders = Object.fromEntries(response.headers.entries()) + res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) if (response.body === null) { res.end() + if (requestUnread) req.destroy() return } for await (const chunk of response.body) { @@ -96,4 +107,5 @@ export async function bridge( } } res.end() + if (requestUnread) req.destroy() } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 34cf79bd65..a274cef190 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -22,6 +22,7 @@ export type { ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, + ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a9bbc72954..d74734e132 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -8,7 +8,7 @@ import { type RpcId as RpcIdType, } from './rpc.ts' import { clientRequestSchema } from './rpc-schema.ts' -import { bridge, type FetchHandler } from './http-bridge.ts' +import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' @@ -34,11 +34,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher - readonly fetchHandler: FetchHandler + readonly fetchHandler: ConnectionFetchHandler } interface RegisteredFetchRoute { readonly methods: ReadonlySet + readonly requestBody: ConnectionFetchRoute['requestBody'] readonly fetch: ConnectionFetchRoute['fetch'] } @@ -117,6 +118,10 @@ export class HostConnectionService extends Service implements HostConnectionHand channel: '/api', ): ConnectionFetchHandler { return { + requestBodyMode: ({ method, url }) => { + const route = this.fetchRoutes.get(url.pathname) + return route?.methods.has(method) === true ? route.requestBody : 'buffered' + }, fetch: (request) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) @@ -138,6 +143,7 @@ export class HostConnectionService extends Service implements HostConnectionHand assertFetchRoute(route) const registered: RegisteredFetchRoute = { methods: new Set(route.methods), + requestBody: route.requestBody, fetch: route.fetch, } return owner.effect(() => { @@ -203,8 +209,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): FetchHandler { +): ConnectionFetchHandler { return { + requestBodyMode: () => 'buffered', async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 12c8f198be..9474d72c36 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -107,7 +107,10 @@ export type ConnectionRpcHandler = ( export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean /** HTTP methods supported by exact Fetch routes on the shared API channel. */ -export type ConnectionFetchMethod = 'GET' | 'HEAD' +export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST' + +/** How the node:http bridge presents one request body to its Fetch route. */ +export type ConnectionRequestBodyMode = 'buffered' | 'streaming' /** One exact, transport-independent Fetch route owned by a Host feature. */ export interface ConnectionFetchRoute { @@ -115,6 +118,8 @@ export interface ConnectionFetchRoute { readonly path: string /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ readonly methods: readonly ConnectionFetchMethod[] + /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ + readonly requestBody: ConnectionRequestBodyMode /** Handle one request after the physical carrier has applied its trust and authentication policy. */ readonly fetch: (request: Request) => Promise } @@ -196,6 +201,13 @@ export interface HostConnectionHandle { /** Transport-independent Fetch handler used by HTTP and worker carriers. */ export interface ConnectionFetchHandler { + /** + * Resolve body handling before the bridge reads any request bytes. + * @param request - request method and URL available from node:http headers. + * @returns the registered route's body handling mode. + */ + requestBodyMode(request: { readonly method: string; readonly url: URL }): ConnectionRequestBodyMode + /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 1ce037462f..a4549c4970 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -8,6 +8,7 @@ "files": [ "src/api-path.ts", "src/client/api.ts", + "src/client/background-upload.ts", "src/client/connection.ts", "src/client/fixture.ts", "src/client/index.ts", diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 1d04fe7eb7..384597a9b2 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -9,7 +9,7 @@ export const zh = { 'status.empty': '无选项', 'overlay.aria': '/{command} 选项', 'listbox.aria': '/{command} 匹配项', - 'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'notice.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', } satisfies Record /** The command namespace key union. */ @@ -24,5 +24,5 @@ export const en = { 'status.empty': 'No options', 'overlay.aria': '/{command} options', 'listbox.aria': '/{command} matches', - 'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'notice.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', } satisfies Record diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 4a606fe607..a8b1c86dad 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -19,7 +19,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, - SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, + SubmitAttachment, SubmitEnvelope, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -310,10 +310,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * bare host commands act on the bare token only; leadingInput claims * args-tolerant. * - * Envelope policy: an enter submission carrying images resolves only - * through a command declaring image acceptance. Every other command route — + * Envelope policy: an enter submission carrying attachments resolves only + * through a command declaring attachment acceptance. Every other command route — * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and images + * so the machine surfaces one composer notice and the draft and attachments * stay in place; nothing executes and nothing is dropped. */ private async matchEnter( @@ -329,13 +329,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const bare = ws === -1 const name = token.slice(1) if (name === '') return undefined - const refuseImages = (): never => { - throw new Error(this.t('notice.imagesUnsupported', { command: name })) + const refuseAttachments = (): never => { + throw new Error(this.t('notice.attachmentsUnsupported', { command: name })) } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } @@ -347,17 +347,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } if (desc.input !== undefined) { - if (envelope.images > 0 && desc.input.images !== true) refuseImages() + if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments() return { claim: this.leadingClaim(desc, session) } } if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.consumeVia(session.sessionId, { via: 'enter', token }) this.runDetached(desc, session, trimmed) return 'handled' @@ -381,8 +381,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), - submit: (args, _actx, images) => this.execute(session, token + args, images), + ...(desc.input?.attachments === true ? { attachments: true } : {}), + submit: (args, _actx, attachments) => this.execute(session, token + args, attachments), } } @@ -394,21 +394,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * executor durably logged the lifecycle (`command/run`/`command/done`) and * the outcome renders as a persistent flow node — the composer never * echoes it. A handler error result reports an error outcome so the - * composer keeps the submission (draft and images) for correction. + * composer keeps the draft and attachments for correction. * A refused call throws. */ private async execute( session: ClientSessionContext, line: string, - images: readonly SubmitImageAttachment[] = [], + attachments: readonly SubmitAttachment[] = [], ): Promise { - const result = await this.ctx.remote.commands.execute(session.sessionId, line, images) + const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) - // An image-carrying submission consumed its images only on handler - // success; an error outcome keeps draft and images in the composer. - if (images.length > 0 && result.value.result.kind === 'error') { + // A submission consumes its attachments only after handler success; an + // error outcome keeps the draft and attachments in the composer. + if (attachments.length > 0 && result.value.result.kind === 'error') { return { kind: 'error', text: result.value.result.text } } return { kind: 'success' } diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 4860b5ce9d..9141210c0e 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -25,7 +25,7 @@ export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick, - InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, + InputTriggerSource, SubmitAttachment, SubmitEnvelope, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index bd862a71a1..acbe2a65a3 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -14,7 +14,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, - InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment, + InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -95,8 +95,8 @@ export interface HeaderRequest { * presence to accept or refuse a whole submission. */ export interface SubmitEnvelope { - /** Number of image attachments accompanying the draft. */ - readonly images: number + /** Number of attachments accompanying the draft. */ + readonly attachments: number } /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 5bf98cc640..4d5a1d28e0 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -119,10 +119,11 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne } } -/** Normalize a RequestInit body to a transferable ArrayBuffer. */ -function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined { +/** Keep opaque Blobs clone-cheap; normalize text and typed arrays to transferable bytes. */ +function toTunnelBody(body: RequestInit['body']): ArrayBuffer | Blob | undefined { if (body === undefined || body === null) return undefined if (typeof body === 'string') return encoder.encode(body).buffer + if (body instanceof Blob) return body if (body instanceof ArrayBuffer) return body if (ArrayBuffer.isView(body)) { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) @@ -205,7 +206,7 @@ export class WorkerTunnel { headers: Object.fromEntries(new Headers(init?.headers).entries()), ...(init?.body === undefined || init.body === null ? {} - : { body: toBodyBuffer(init.body) }), + : { body: toTunnelBody(init.body) }), } const response = new Promise((resolve, reject) => { this.unary.set(id, { resolve, reject }) diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index a04a2cec5a..e591fac007 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -14,7 +14,7 @@ export interface TunnelRequestFrame { readonly method: string readonly url: string readonly headers: Readonly> - readonly body?: ArrayBuffer | undefined + readonly body?: ArrayBuffer | Blob | undefined } /** Open one Gateway Remote stream over the worker-local carrier. */ @@ -171,8 +171,8 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof value === 'string') headers[key.toLowerCase()] = value } const body = frame.body - if (body !== undefined && !(body instanceof ArrayBuffer)) { - throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer`) + if (body !== undefined && !(body instanceof ArrayBuffer) && !(body instanceof Blob)) { + throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer or Blob`) } return { t: 'req', id, method: frame.method, url: frame.url, headers, body } } diff --git a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts index a10634e493..29e5ea4926 100644 --- a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts +++ b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts @@ -66,7 +66,15 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons headers: frame.headers, destroy: (): void => { aborted = true }, async *[Symbol.asyncIterator](): AsyncGenerator { - if (frame.body === undefined || frame.body.byteLength === 0) return + if (frame.body === undefined) return + if (frame.body instanceof Blob) { + for await (const chunk of frame.body.stream()) { + if (aborted) return + if (chunk.byteLength > 0) yield chunk + } + return + } + if (aborted || frame.body.byteLength === 0) return yield new Uint8Array(frame.body) }, } @@ -133,6 +141,7 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons if (finished) return aborted = true finished = true + emit('aborted') emit('close') }, } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 55c4f977ef..1be859503f 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -433,13 +433,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentContext', declaration: 'export type AgentContext = Omit & {\n readonly remote: ClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};', }, + { + name: 'BackgroundUploadProgress', + declaration: 'export interface BackgroundUploadProgress {\n readonly loaded: number;\n readonly total?: number;\n}', + }, + { + name: 'BackgroundUploadRequest', + declaration: 'export interface BackgroundUploadRequest {\n readonly path: string;\n readonly body: Blob;\n readonly headers?: Readonly>;\n readonly signal?: AbortSignal;\n readonly onProgress?: (progress: BackgroundUploadProgress) => void;\n}', + }, + { + name: 'BackgroundUploadResponse', + declaration: 'export interface BackgroundUploadResponse {\n readonly status: number;\n readonly body: string;\n}', + }, + { + name: 'BackgroundUploadTransport', + declaration: 'export interface BackgroundUploadTransport {\n post(request: BackgroundUploadRequest): Promise;\n}', + }, { name: 'BakedActions', declaration: 'export type BakedActions> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};', }, { name: 'BeginSubmissionInput', - declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', + declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', }, { name: 'BoundActions', @@ -499,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n readonly backgroundUploads?: BackgroundUploadTransport;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', @@ -533,6 +549,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EntryKeyOf', declaration: 'export type EntryKeyOf = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;', }, + { + name: 'FileUploadReceiptId', + declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', + }, { name: 'GlobalStandardProps', declaration: 'export interface GlobalStandardProps {\n}', @@ -559,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: number): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n uploadFile(data: Blob | Uint8Array, name?: string, signal?: AbortSignal, onProgress?: (progress: {\n readonly loaded: number;\n readonly total?: number;\n }) => void): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: number): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', @@ -615,7 +635,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmission', - declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}', + declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n}', + }, + { + name: 'PendingSubmissionAttachment', + declaration: 'export type PendingSubmissionAttachment = ({\n readonly type: \'image\';\n} & PendingSubmissionImage) | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', }, { name: 'PendingSubmissionImage', @@ -627,7 +651,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmissionRetirement', - declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};', + declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[];\n} | {\n readonly reason: \'failed\';\n};', }, { name: 'ProjectionsFace', @@ -635,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptContentPart', - declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n} | {\n readonly type: \'file\';\n readonly receiptId: FileUploadReceiptId;\n};', }, { name: 'PromptError', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 81890bfd98..f5a05c5dbf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -407,7 +407,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:119', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', }, { key: 'conversation.composer.bar', @@ -443,7 +443,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.composer.dock', @@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', }, { key: 'conversation.details.tool', @@ -565,7 +565,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', }, { key: 'conversation.hero.brand.mark', @@ -591,7 +591,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', }, { key: 'conversation.hero.workspace', @@ -621,7 +621,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -656,15 +656,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.input.attachments', kind: 'single', scope: 'session-maybe', - summary: 'Optional draft-image rail and drop target.', - doc: 'Optional draft-image rail and drop target.', + summary: 'Optional draft-attachment rail and drop target.', + doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the Conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddFiles: (files: readonly File[]) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', 'DraftAttachmentId', + 'DraftFileUploads', ], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', @@ -687,7 +688,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.input.dock', @@ -747,7 +748,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153', }, { key: 'conversation.input.left', @@ -803,7 +804,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.input.model', @@ -839,7 +840,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.input.overlay', @@ -893,7 +894,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', }, { key: 'conversation.input.plan', @@ -929,7 +930,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.input.right', @@ -985,7 +986,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.message.images', @@ -995,7 +996,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1059,7 +1060,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', }, { key: 'conversation.session.header', @@ -1093,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:97', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', }, { key: 'conversation.session.header.actions', @@ -1151,7 +1152,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'conversation.session.header.lineage', @@ -1189,7 +1190,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:99', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', }, { key: 'conversation.session.header.utilities', @@ -1244,7 +1245,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:111', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', }, { key: 'conversation.trajectory.images', @@ -1254,7 +1255,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1342,7 +1343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:117', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143', }, { key: 'details', diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 419e155af7..5c3b8b0250 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -120,6 +120,14 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `uploadFile` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + uploadFile(): never { + throw new Error(`test session "${this.sessionId}": uploadFile is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it. * @returns never — always throws. From a1144c4950721243efc3f493e906e1df3ed9fcae Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:38:36 +0800 Subject: [PATCH 04/37] feat(ui-attachment): unify mixed attachment presentation --- .../src/AttachmentRail.module.css | 60 +--- .../ui-attachment/src/AttachmentRail.tsx | 59 +--- .../ui-attachment/src/FileCard.module.css | 151 +++++++++ .../client/ui-attachment/src/FileCard.tsx | 89 +++++ .../client/ui-attachment/src/MessageImage.tsx | 7 +- .../src/client/ComposerAttachments.module.css | 60 ++++ .../src/client/ComposerAttachments.tsx | 65 +++- .../src/client/MessageImages.tsx | 12 +- .../client/ui-attachment/src/client/labels.ts | 32 +- .../src/client/chat/MessageItem.module.css | 54 +++ .../ui-chat/src/client/chat/MessageItem.tsx | 90 +++-- .../ui-conversation/src/client/apply.ts | 72 +++- .../src/client/contract/input.ts | 58 ++-- .../src/client/contract/slots.ts | 56 +++- .../src/client/image-labels.ts | 3 + .../ui-conversation/src/client/index.ts | 6 +- .../src/client/input/facade.ts | 163 ++++----- .../ui-conversation/src/client/input/hub.ts | 31 +- .../src/client/input/machine.ts | 4 +- .../ui-conversation/src/client/locales.ts | 48 ++- .../src/client/queue/QueueDock.module.css | 43 ++- .../src/client/queue/QueueDock.tsx | 117 ++++--- .../ui-conversation/src/client/service.ts | 314 ++++++++++++++---- .../src/client/skeleton/InputBar.tsx | 115 +++++-- .../ui-primitives/src/DocumentFileIcon.tsx | 41 +++ .../client/ui-primitives/src/file-size.ts | 16 + packages/client/ui-primitives/src/index.ts | 2 + .../client/ui-trajectory/src/client/layout.ts | 15 +- .../ui-trajectory/src/client/locales.ts | 2 + 29 files changed, 1323 insertions(+), 462 deletions(-) create mode 100644 packages/client/ui-attachment/src/FileCard.module.css create mode 100644 packages/client/ui-attachment/src/FileCard.tsx create mode 100644 packages/client/ui-primitives/src/DocumentFileIcon.tsx create mode 100644 packages/client/ui-primitives/src/file-size.ts diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css index f2624bdf7d..0e25035f4c 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.module.css +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -5,6 +5,7 @@ .rail { display: flex; + align-items: stretch; gap: 10px; overflow-x: auto; overflow-y: hidden; @@ -22,67 +23,10 @@ } .item { - position: relative; - flex: 0 0 64px; - width: 64px; + flex: none; height: 64px; } -.thumbnail { - width: 64px; - height: 64px; - padding: 0; - overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 16px; - background: var(--dsw-alias-interactive-bg-hover); - cursor: zoom-in; -} - -.thumbnail img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; -} - -.remove { - position: absolute; - top: 4px; - right: 4px; - z-index: 1; - display: grid; - place-items: center; - width: 18px; - height: 18px; - padding: 0; - border: none; - border-radius: 50%; - background: var(--dsw-alias-button-contrast-fill); - color: var(--dsw-alias-label-primary-inverted); - cursor: pointer; - opacity: 0; - transition: opacity 0.2s ease-in-out; -} - -.item:hover .remove, -.remove:focus-visible { - opacity: 1; -} - -/* Touch surfaces have no hover to reveal the control. */ -@media (pointer: coarse) { - .remove { - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .remove { - transition: none; - } -} - .arrow { position: absolute; top: 50%; diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index 65e2df109b..e2e8f97321 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -1,31 +1,23 @@ -/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged - * by edge arrows, hover-revealed per-item remove, single-click open. */ +/** Draft-attachment rail: scrollbar-less horizontal overflow paged by edge arrows. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, + IconChevronLeftOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './AttachmentRail.module.css' -/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */ +/** One ordered draft attachment rendered by the rail owner. */ export interface AttachmentRailItem { /** Stable identity for the React key. */ id: string - /** Object or data URL rendered as the thumbnail. */ - previewUrl: string - /** Image alt text (display name with the owner's fallback applied). */ - alt: string - /** Accessible label of the item's remove control. */ - removeLabel: string } /** Rail-level strings the owner resolves from its own locale namespace. */ export interface AttachmentRailLabels { /** Accessible name of the rail group. */ group: string - /** Thumbnail tooltip inviting the original-image preview. */ - open: string /** Accessible label of the left paging arrow. */ scrollLeft: string /** Accessible label of the right paging arrow. */ @@ -40,12 +32,12 @@ const WHEEL_LINE_PX = 16 function pageBehavior(): ScrollBehavior { // jsdom (the unit lane) implements no matchMedia despite lib.dom's // non-optional typing; the optional call keeps that lane on the default. - // oxlint-disable-next-line typescript/no-unnecessary-condition - return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' + const matchMedia = (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia + return matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' } /** - * Horizontal thumbnail rail over the caller's draft attachments. + * Horizontal rail over the caller's ordered draft attachments. * * The rail scrolls with its scrollbar hidden; overflow is announced by edge * arrows recomputed from scroll geometry on scroll, item-count changes, and @@ -53,22 +45,18 @@ function pageBehavior(): ScrollBehavior { * panel resizes count, not only window resizes). A vertical wheel pans the * rail horizontally and is consumed exclusively (non-passive listener), a * newly added item is revealed at the rail's end while a rail that mounts - * over an existing draft keeps its start position, and each thumbnail opens - * on a single click while its remove control sits inside the card and - * reveals on hover or focus. The owner decides mounting; it renders the rail - * only while items exist. + * over an existing draft keeps its start position. The owner renders each + * item and decides mounting; it renders the rail only while items exist. * - * @param props.items - resolved thumbnails in draft order. - * @param props.labels - rail-level strings (group name, open tooltip, arrows). - * @param props.onOpen - single-click open of one item's original image. - * @param props.onRemove - remove one item from the draft. + * @param props.items - attachments in draft order. + * @param props.labels - rail-level strings (group name and paging arrows). + * @param props.renderItem - render one attachment card in draft order. * @returns the rail group with its paging arrows. */ -export function AttachmentRail({ items, labels, onOpen, onRemove }: { +export function AttachmentRail({ items, labels, renderItem }: { items: readonly T[] labels: AttachmentRailLabels - onOpen: (item: T) => void - onRemove: (item: T) => void + renderItem: (item: T) => ReactNode }) { const railRef = useRef(null) // null marks the first layout pass: a rail that MOUNTS over an existing @@ -165,24 +153,7 @@ export function AttachmentRail({ items, labels, on onScroll={updateEdges} > {items.map(item => ( -
- - -
+
{renderItem(item)}
))} {edges.right && ( diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css new file mode 100644 index 0000000000..3925088b66 --- /dev/null +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -0,0 +1,151 @@ +.card { + position: relative; + display: inline-flex; + align-items: center; + gap: 10px; + width: 240px; + height: 64px; + padding: 0 12px; + border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border-radius: 16px; + background: var(--dsw-specific-input-major, transparent); + box-sizing: border-box; + text-align: left; +} + +.failed { + border-color: var(--dsw-alias-state-error-primary, #d54941); +} + +.icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: file-card-spin 0.8s linear infinite; +} + +@keyframes file-card-spin { + to { transform: rotate(360deg); } +} + +.body { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + padding: 8px 0; +} + +.retry { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.meta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + font-size: 12px; + line-height: 15px; +} + +.metaFailed { + color: var(--dsw-alias-state-error-primary, #d54941); +} + +.remove { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.72)); + color: var(--dsw-alias-label-primary-inverted, #fff); + opacity: 0; + cursor: pointer; + transition: opacity 0.2s ease-in-out; +} + +.card:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +.card:hover .name, +.card:focus-within .name { + padding-right: 18px; +} + +.removeFailed { + background: var(--dsw-alias-state-error-primary, #d54941); + color: #fff; + opacity: 1; +} + +.progressTrack { + position: absolute; + right: 12px; + bottom: 5px; + left: 12px; + overflow: hidden; + height: 2px; + border-radius: 1px; + background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); +} + +.progressBar { + display: block; + width: 35%; + height: 100%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: file-card-progress 1.2s ease-in-out infinite alternate; +} + +.progressBar[style] { + animation: none; +} + +@keyframes file-card-progress { + from { transform: translateX(-70%); } + to { transform: translateX(220%); } +} + +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx new file mode 100644 index 0000000000..d5634b7d9a --- /dev/null +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -0,0 +1,89 @@ +import { DocumentFileIcon, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './FileCard.module.css' + +/** Localized strings consumed by one pending-file card. */ +export interface FileCardLabels { + /** Card body announcement, e.g. "Pending file {name}". */ + readonly label: string + /** Remove-button label. */ + readonly remove: string + /** Status line while the upload is in flight. */ + readonly uploading: string + /** Status line and retry affordance after a failed upload. */ + readonly failed: string + /** Retry-button label. */ + readonly retry: string +} + +/** Upload display state resolved by the owner. */ +export type FileCardState = 'uploading' | 'ready' | 'error' + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot + 1).toUpperCase().slice(0, 8) +} + +/** One pending generic-file card: name, size or upload status, remove, retry. */ +export function FileCard({ + name, bytes, state, progress, labels, onRemove, onRetry, +}: { + name: string + bytes: number + state: FileCardState + progress?: number + labels: FileCardLabels + onRemove: () => void + onRetry: () => void +}) { + const extension = extensionOf(name) + const meta = state === 'uploading' + ? labels.uploading + : state === 'error' + ? labels.failed + : [extension, fileSizeText(bytes)].filter(part => part !== '').join(' ') + const retryable = state === 'error' + return ( +
+ + {state === 'uploading' + ? + : } + + {retryable + ? ( + + ) + : ( + + {name} + {meta} + + )} + + {state === 'uploading' && ( + + + + )} +
+ ) +} diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 61554fe93f..3cf3b6c51e 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -139,15 +139,16 @@ export function MessageImage({ image, load, variant, labels }: { } /** Wrapping image group shared by user and assistant history: a lone image - * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ -export function ImageGallery({ images, load, align, labels }: { + * renders large unless its owning mixed-attachment row requests compact tiles. */ +export function ImageGallery({ images, load, align, compact = false, labels }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' + compact?: boolean labels: MessageImageLabels }) { if (images.length === 0) return null - const variant = images.length === 1 ? 'single' : 'tile' + const variant = compact || images.length > 1 ? 'tile' : 'single' return (
{images.map((image, index) => ( diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 770a64cef5..8fcb9f1134 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -2,3 +2,63 @@ min-width: 0; padding: 4px 12px 0; } + +.imageItem { + position: relative; + width: 64px; + height: 64px; +} + +.thumbnail { + width: 64px; + height: 64px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 16px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.imageItem:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .remove { + transition: none; + } +} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 1e45dc6230..fd469195bf 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { - ComposerAttachment, ComposerAttachmentsProps, + ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' +import { FileCard } from '../FileCard.tsx' import { ImageLightbox } from '../ImageLightbox.tsx' -import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts' +import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts' import css from './ComposerAttachments.module.css' /** Rail item retaining its browser-owned attachment for callbacks. */ @@ -14,15 +16,14 @@ interface ComposerRailItem extends AttachmentRailItem { attachment: ComposerAttachment } -/** Draft-image rail, document drop target, and original-image preview slot entry. */ +/** Draft image previews, pending-file cards, drop target, and original-image preview. */ export function ComposerAttachments({ - attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t, + attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t, }: ComposerAttachmentsProps) { - const [preview, setPreview] = useState(null) + const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) const dragDepth = useRef(0) const closePreview = useCallback(() => { setPreview(null) }, []) - useEffect(() => { if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) }, [attachments, preview]) @@ -62,7 +63,7 @@ export function ComposerAttachments({ if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddImages([...dataTransfer.files]) + if (canAcceptDrop) onAddFiles([...dataTransfer.files]) } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) @@ -76,15 +77,12 @@ export function ComposerAttachments({ document.removeEventListener('drop', onDrop) window.removeEventListener('dragend', reset) } - }, [canAcceptDrop, onAddImages]) + }, [canAcceptDrop, onAddFiles]) const railItems = useMemo(() => attachments.map(attachment => ({ id: attachment.id, - previewUrl: attachment.previewUrl, - alt: attachment.file.name || t('image.pending'), - removeLabel: t('image.remove', { name: attachment.file.name }), attachment, - })), [attachments, t]) + })), [attachments]) return ( <> @@ -99,8 +97,47 @@ export function ComposerAttachments({ { setPreview(item.attachment) }} - onRemove={(item) => { onRemoveImage(item.attachment.id) }} + renderItem={(item) => { + const attachment = item.attachment + if (attachment.kind === 'file') { + const upload = uploads[attachment.id] + return ( + 0 + ? { progress: upload.loaded / upload.total } + : {}} + labels={fileCardLabels(t, attachment.file.name)} + onRemove={() => { onRemoveAttachment(attachment.id) }} + onRetry={() => { onRetryFile(attachment.id) }} + /> + ) + } + return ( +
+ + +
+ ) + }} />
)} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index c84914db54..92cdaa455b 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -3,6 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' /** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) { - return +export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) { + return ( + + ) } diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts index 4dcc3567db..990f8959c7 100644 --- a/packages/client/ui-attachment/src/client/labels.ts +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -1,6 +1,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { AttachmentRailLabels } from '../AttachmentRail.tsx' import type { DropOverlayLabels } from '../DropOverlay.tsx' +import type { FileCardLabels } from '../FileCard.tsx' import type { ImageLightboxLabels } from '../ImageLightbox.tsx' import type { MessageImageLabels } from '../MessageImage.tsx' @@ -41,23 +42,38 @@ export function dropOverlayLabels( accepting: boolean, limits?: { readonly count: number; readonly size: string }, ): DropOverlayLabels { - if (!accepting) return { title: t('image.dropBlocked') } + if (!accepting) return { title: t('attachment.dropBlocked') } return { - title: t('image.dropTitle'), - desc: limits === undefined ? undefined : t('image.dropDesc', limits), + title: t('attachment.dropTitle'), + desc: limits === undefined ? undefined : t('attachment.dropDesc', limits), } } /** - * Resolve draft-image rail strings from the conversation namespace. + * Resolve pending-file card strings from the conversation namespace. + * @param t - conversation namespace translator. + * @param name - browser file name interpolated into remove/retry labels. + * @returns translated file-card labels. + */ +export function fileCardLabels(t: TranslateNS<'conversation'>, name: string): FileCardLabels { + return { + label: t('file.pending'), + remove: t('file.remove', { name }), + uploading: t('file.uploading'), + failed: t('file.uploadFailed'), + retry: t('file.retry', { name }), + } +} + +/** + * Resolve the mixed draft-attachment rail strings from the conversation namespace. * @param t - conversation namespace translator. * @returns translated attachment-rail labels. */ export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels { return { - group: t('image.pending'), - open: t('image.openOriginal'), - scrollLeft: t('image.scrollLeft'), - scrollRight: t('image.scrollRight'), + group: t('attachment.pending'), + scrollLeft: t('attachment.scrollLeft'), + scrollRight: t('attachment.scrollRight'), } } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 7c616f4a0e..fd90d1ebec 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -292,3 +292,57 @@ animation: none; } } + +.attachmentRow { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 100%; + gap: 8px; +} + +.fileCard { + display: inline-flex; + flex: 0 0 240px; + align-items: center; + gap: 10px; + width: 240px; + min-height: 64px; + padding: 8px 12px; + border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border-radius: 16px; + background: var(--dsw-specific-input-major, transparent); + box-sizing: border-box; +} + +.fileIcon { + flex: none; + width: 24px; + height: 28px; +} + +.fileContent { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.fileName { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.fileMeta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + font-size: 12px; + line-height: 15px; +} diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 9846a472d6..23fae8a4a6 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -11,24 +11,37 @@ import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' type UserImage = Extract +type UserFile = Extract +type PresentedAttachment = + | { readonly type: 'image'; readonly image: MessageImageSource } + | { readonly type: 'file'; readonly file: UserFile['attachment'] } + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot + 1).toUpperCase().slice(0, 8) +} function contentParts(content: readonly unknown[]): { text: string - images: { attachment: UserImage['attachment'] }[] + attachments: PresentedAttachment[] rest: unknown[] } { const texts: string[] = [] - const images: { attachment: UserImage['attachment'] }[] = [] + const attachments: PresentedAttachment[] = [] const rest: unknown[] = [] for (const block of content) { const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) else if (b.type === 'image' && b.attachment !== undefined) { - images.push({ attachment: (b as UserImage).attachment }) + attachments.push({ type: 'image', image: { attachment: (b as UserImage).attachment } }) + } + else if (b.type === 'file' && b.attachment !== undefined) { + attachments.push({ type: 'file', file: (b as UserFile).attachment }) } else rest.push(block) } - return { text: texts.join(''), images, rest } + return { text: texts.join(''), attachments, rest } } function retrySeconds(milliseconds: number): number { @@ -148,7 +161,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewAttachments, reveal = 'always', t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -160,14 +173,15 @@ function UserStyleBubble({ echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] - /** Local submission-echo previews replacing the content-derived image group. */ - previewImages?: readonly MessageImageSource[] + /** Local submission-echo attachments replacing the content-derived attachment sequence. */ + previewAttachments?: readonly PresentedAttachment[] /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown (turn tails' gate). */ reveal?: 'always' | 'hover' t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images: contentImages, rest } = contentParts(content) - const images = previewImages ?? contentImages + const { text, attachments: contentAttachments, rest } = contentParts(content) + const attachments = previewAttachments ?? contentAttachments + const compactImages = attachments.length > 1 const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( @@ -178,7 +192,32 @@ function UserStyleBubble({ data-actions-reveal={reveal} >
- {renderMessageImages({ images, align: 'end' })} + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => attachment.type === 'image' + ? ( + + {renderMessageImages({ + images: [attachment.image], + align: 'end', + compact: compactImages, + })} + + ) + : ( + + + + {attachment.file.name} + + {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + .filter(Boolean).join(' ')} + + + + ))} +
+ )} {showBubble &&
{projectUserText(text, referenceLabels)} {rest.map((block, i) => )} @@ -240,21 +279,26 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), [submission.text], ) - const previewImages = useMemo( - () => submission.images.map(image => ({ - preview: { - url: image.previewUrl, - ...(image.name === undefined ? {} : { name: image.name }), - ...(image.width === undefined ? {} : { width: image.width }), - ...(image.height === undefined ? {} : { height: image.height }), - }, - })), - [submission.images], + const previewAttachments = useMemo( + () => submission.attachments.map(attachment => attachment.type === 'image' + ? { + type: 'image', + image: { + preview: { + url: attachment.previewUrl, + ...(attachment.name === undefined ? {} : { name: attachment.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + }, + }, + } + : { type: 'file', file: attachment.attachment }), + [submission.attachments], ) return ( = z.object({ + maxConcurrentFileUploads: z.natural().min(1).default(2), +}) + // Stable no-session sources keep the renderer's observable-hook cache and // hook order unchanged across current-Session transitions. const ABSENT_NOTICES = { @@ -65,6 +77,11 @@ const ABSENT_MENU_LAUNCHER = { getSnapshot: (): string | null => null, subscribe: () => () => {}, } +const EMPTY_FILE_UPLOADS: DraftFileUploads = {} +const ABSENT_FILE_UPLOADS = { + getSnapshot: () => EMPTY_FILE_UPLOADS, + subscribe: () => () => {}, +} interface WorkspaceNavigation { connectWorkspace( @@ -94,9 +111,11 @@ function concreteConversation(ctx: Context): ConversationController { * Mount the Conversation core and target-neutral presentation. * @param ctx - Client root context. */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config: Config = Config({})): void { const sessions = ctx.sessions const slots = ctx.slots + // Schemastery's field default is materialized before Cordis calls apply. + const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) @@ -220,15 +239,20 @@ export function apply(ctx: Context): void { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft - const imageIds = from.snapshot.imageIds + const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) - if (imageIds.length === 0 || next.addImages(imageIds)) { + if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { + const target = sessions.binding(nextId)?.session + if (target === undefined) { + throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) + } + concreteConversation(ctx).rebindDraftFiles(target, attachmentIds) if (draft !== '') { next.setDraft(draft) from.setDraft('') } - if (imageIds.length > 0) { - for (const id of imageIds) from.removeImage(id) + if (attachmentIds.length > 0) { + for (const id of attachmentIds) from.removeAttachment(id) } } } @@ -284,15 +308,17 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, - addImages: undefined, - removeImage: undefined, - draftImages: undefined, + addFiles: undefined, + removeAttachment: undefined, + resolveDraftAttachments: undefined, + retryFileUpload: undefined, resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: undefined, stop: undefined, command: undefined, hooks: { + fileUploads: ABSENT_FILE_UPLOADS, notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER, @@ -304,11 +330,13 @@ export function apply(ctx: Context): void { const inputTriggers = inputHub.inputTriggers(sessionId) return { keyboard: shell, - addImages: (files) => { + addFiles: (files) => { + const session = sessions.binding(sessionId)?.session + if (session === undefined) return t('file.sessionUnavailable') try { - const images = conversation.createDraftImages(files) - if (!shell.addImages(images.map(image => image.id))) { - conversation.releaseDraftImages(images) + const drafts = conversation.createDrafts(session, files) + if (!shell.addAttachments(drafts.map(draft => draft.id))) { + conversation.releaseDraftAttachments(drafts) } return null } catch (error: unknown) { @@ -316,11 +344,14 @@ export function apply(ctx: Context): void { return error instanceof Error ? error.message : String(error) } }, - removeImage: (id) => { - conversation.releaseDraftImage(id) - shell.removeImage(id) + removeAttachment: (id) => { + if (shell.removeAttachment(id)) conversation.releaseDraftAttachment(id) + }, + resolveDraftAttachments: ids => conversation.resolveDraftAttachments(ids), + retryFileUpload: (id) => { + const session = sessions.binding(sessionId)?.session + if (session !== undefined) conversation.retryFileUpload(session, id) }, - draftImages: ids => conversation.draftImages(ids), resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: inputTriggers === undefined @@ -348,6 +379,7 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, hooks: { + fileUploads: conversation.fileUploads, notices: shell.notices, lexicon: shell.lexicon, menuLauncher: inputTriggers?.launcher ?? ABSENT_MENU_LAUNCHER, @@ -363,7 +395,11 @@ export function apply(ctx: Context): void { yield registerComposerBar() }) - ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks }) + ctx.plugin(ConversationController, { + input: inputHub, + blocks: composerBlocks, + maxConcurrentFileUploads, + }) ctx.plugin(todoDockEntry) ctx.plugin(queueDockEntry) } diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index d6a0741200..9d65d84f1f 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -20,11 +20,19 @@ export interface TokenSpan { readonly draftRev: number } -/** Base64 image payload passed to a claimed command submission. */ -export interface SubmitImageAttachment { - readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' - readonly data: string - readonly name?: string +/** Attachment payload passed to a claimed command submission. */ +export type SubmitAttachment = + | { + readonly type: 'image' + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string + } + | { readonly type: 'file'; readonly receiptId: string } + +/** Command serialization result for one ordered attachment draft. */ +export interface DraftAttachmentSerializationResult { + readonly attachments: readonly SubmitAttachment[] } /** Settled result of a command or default composer submission. */ @@ -37,15 +45,15 @@ export interface SubmitOutcome { export interface CommandClaim { readonly token: string readonly hint?: string - readonly images?: boolean + readonly attachments?: boolean /** * Submit the claimed command. * @param args - command text after the claimed token. * @param actx - current Session scope. - * @param images - serialized draft images accepted by the claim. + * @param attachments - serialized draft attachments accepted by the claim. * @returns command settlement. */ - submit(args: string, actx: Context, images: readonly SubmitImageAttachment[]): Promise + submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise } /** Structured reference inserted by an input-trigger source. */ @@ -127,7 +135,7 @@ export interface InputTriggerController { adjudicate( line: string, signal: AbortSignal, - envelope: { readonly images: number }, + envelope: { readonly attachments: number }, ): Promise /** @param source - source name. @param hit - synthetic trigger hit. */ toggleSource(source: string, hit: InputTriggerHit): void @@ -162,7 +170,7 @@ declare module '@deepseek-ai/cordis' { } } -/** Browser-runtime identity of one unsent image draft. */ +/** Browser-runtime identity of one unsent attachment draft. */ export type DraftAttachmentId = Branded<'DraftAttachmentId'> /** @@ -181,12 +189,12 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void - /** Append ordered browser-owned image ids; busy admission phases refuse. */ - addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id; busy admission phases refuse. */ - removeImage(id: DraftAttachmentId): void + /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned attachment id; busy admission phases refuse. @returns whether the id was removed. */ + removeAttachment(id: DraftAttachmentId): boolean /** Drop ids whose browser-owned objects no longer exist. */ - pruneImages(ids: readonly DraftAttachmentId[]): void + pruneAttachments(ids: readonly DraftAttachmentId[]): void /** * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. * @param mode - delivery intent retained through asynchronous adjudication and serialization. @@ -221,12 +229,12 @@ export interface SessionInputResolver { export interface InputActions { /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void - /** Append ordered browser-owned image ids; busy admission phases refuse. */ - addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id; busy admission phases refuse. */ - removeImage(id: DraftAttachmentId): void + /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned attachment id; busy admission phases refuse. */ + removeAttachment(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ - pruneImages(ids: readonly DraftAttachmentId[]): void + pruneAttachments(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(): void } @@ -321,13 +329,13 @@ export interface Occurrence { export interface InputState { /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */ readonly draft: string - /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */ - readonly imageIds: readonly DraftAttachmentId[] + /** Ordered runtime-only attachment ids; browser objects stay in ConversationController. */ + readonly attachmentIds: readonly DraftAttachmentId[] /** Monotonic editor revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } + readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean } /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Read-only transient inbox projection from Session control, including pending steering. */ @@ -369,7 +377,7 @@ export type InputEvent = | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } /** Settlement of one optimistic default send, independent of the frozen command slot. */ | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** Commit an image-only send whose empty draft did not need an attempt. */ + /** Commit an attachment-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -392,7 +400,7 @@ export type InputEffect = * Clear the committed draft in the editor and cut undo history. A string * snapshot keeps a pure suffix typed during the Host round-trip (content * appended after the sent snapshot survives; interleaved edits cannot be - * separated and clear whole); null clears unconditionally (image-only + * separated and clear whole); null clears unconditionally (attachment-only * sends have no draft to retain). */ | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1f84c8e3f1..918c7180bc 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,7 +1,8 @@ /** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-api-session-controller/types' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, @@ -22,8 +23,11 @@ import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submissi import type { ConversationSnapshot } from './snapshot.ts' import type { ViewTab } from './views.ts' -/** Browser-owned image that has not crossed the durable Host boundary. */ -export interface ComposerAttachment { +/** Browser-owned draft attachment that has not crossed the durable Host boundary. */ +export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment + +/** Browser-owned image, base64-encoded into the prompt at send time. */ +export interface ComposerImageAttachment { kind: 'image' id: DraftAttachmentId file: File @@ -34,16 +38,36 @@ export interface ComposerAttachment { height?: number } +/** Browser-owned generic file whose bytes upload to the Host as soon as it is picked. */ +export interface ComposerFileAttachment { + kind: 'file' + id: DraftAttachmentId + file: File +} + +/** Upload lifecycle of one picked file draft (files upload on pick, not on send). */ +export type DraftFileUpload = + | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number } + | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef } + | { readonly status: 'error'; readonly message: string } + +/** Per-draft upload states keyed by draft attachment id. */ +export type DraftFileUploads = Readonly> + /** Input state handed to the optional attachment presentation plugin. */ export interface ComposerAttachmentsOwnerProps { - /** Browser-owned draft images in input order. */ + /** Browser-owned draft attachments in input order. */ attachments: readonly ComposerAttachment[] - /** Whether a document-level file drop may add images now. */ + /** Whether a document-level file drop may add attachments now. */ canAcceptDrop: boolean /** Add one dropped batch through the composer's validation path. */ - onAddImages: (files: readonly File[]) => void - /** Remove one draft image through the Conversation service. */ - onRemoveImage: (id: DraftAttachmentId) => void + onAddFiles: (files: readonly File[]) => void + /** Remove one draft attachment through the Conversation service. */ + onRemoveAttachment: (id: DraftAttachmentId) => void + /** Current per-draft upload states for file-kind attachments. */ + uploads: DraftFileUploads + /** Restart one failed file upload. */ + onRetryFile: (id: DraftAttachmentId) => void /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } @@ -79,6 +103,8 @@ export interface MessageImagesOwnerProps { loadImage: MessageImageLoader /** Horizontal placement inside the owning record. */ align: 'start' | 'end' + /** Force every image into the compact message-attachment tile size. */ + compact?: boolean } /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ @@ -135,7 +161,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone } /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } - /** Optional draft-image rail and drop target. */ + /** Optional draft-attachment rail and drop target. */ 'conversation.input.attachments': { kind: 'single' scope: 'session-maybe' @@ -268,9 +294,11 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { keyboard: ComposerKeyboard | undefined - addImages: ((files: readonly File[]) => string | null) | undefined - removeImage: ((id: DraftAttachmentId) => void) | undefined - draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined + addFiles: ((files: readonly File[]) => string | null) | undefined + removeAttachment: ((id: DraftAttachmentId) => void) | undefined + resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined + /** Restart one failed file upload; absent without a session. */ + retryFileUpload: ((id: DraftAttachmentId) => void) | undefined resolveSubmitMode: ( running: boolean, gesture: ComposerSubmitGesture, @@ -280,6 +308,8 @@ export interface ComposerBarInjected { stop: (() => void) | undefined command: ((line: string) => Promise) | undefined hooks: { + /** Live per-draft upload states for file-kind drafts. */ + fileUploads: ObservableSnapshot notices: ObservableSnapshot lexicon: ObservableSnapshot> menuLauncher: ObservableSnapshot @@ -357,7 +387,7 @@ export type ConversationSessionHeaderSlotProps = & InjectFace & PropsLocale<'conversation'> -/** Full props of the draft-image attachment renderer. */ +/** Full props of the draft-attachment renderer. */ export type ComposerAttachmentsProps = PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index a783d4f840..feca691412 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -32,6 +32,9 @@ export function attachmentErrorText( ): string { switch (reason) { case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') + // A prompt cited a file the Host has no staged upload for (expired + // process, foreign id): solvable by re-adding the file. + case 'FILE_NOT_STAGED': return t('file.notStaged') case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') case 'IMAGE_DIMENSION_TOO_LARGE': if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension }) diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 102b385e8c..7e9f5a3f3b 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,5 +1,6 @@ /** Browser Conversation assemble core, React adapter, shell, and input plugin. */ -export { apply, inject } from './apply.ts' +export { apply, Config, inject } from './apply.ts' +export type { Config as ConversationConfig } from './apply.ts' export { UiConversation } from './conversation/assembly.ts' export type { ConversationBinding } from './conversation/assembly.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' @@ -47,6 +48,7 @@ export { ConversationViewRegistry } from './conversation/view-registry.ts' export type { ConversationKey } from './locales.ts' export type { ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, + ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, DraftFileUploads, ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps, ConversationHeaderActionOwnerProps, ConversationHeaderLineageOwnerProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps, @@ -59,7 +61,7 @@ export type { export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputState, InsertReferenceRequest, InsertTextRequest, - PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitImageAttachment, + PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitAttachment, SubmitOutcome, TokenSpan, } from './contract/input.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index e2fb81f09b..1cacd83812 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -63,17 +63,17 @@ export interface SessionInputDeps { /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink( text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal: AbortSignal, ): Promise - /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ - commandImages: { + /** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */ + commandAttachments: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ - serialize(ids: readonly DraftAttachmentId[]): Promise - /** Free consumed draft images after a successful command submit. */ + serialize(ids: readonly DraftAttachmentId[]): Promise + /** Free consumed draft attachments after a successful command submit. */ release(ids: readonly DraftAttachmentId[]): void - /** Localized composer notice for a claimed command that does not accept images. */ + /** Localized composer notice for a claimed command that does not accept attachments. */ unsupportedNotice(token: string): string } } @@ -117,7 +117,7 @@ const HISTORY_MERGE_DELAY_MS = 1000 interface DetachedDraft { readonly draft: string readonly occurrences: readonly Occurrence[] - readonly imageIds: readonly DraftAttachmentId[] + readonly attachmentIds: readonly DraftAttachmentId[] } /** @@ -135,9 +135,9 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - addImages: ids => this.addImages(ids), - removeImage: (id) => { this.removeImage(id) }, - pruneImages: (ids) => { this.pruneImages(ids) }, + addAttachments: ids => this.addAttachments(ids), + removeAttachment: (id) => { this.removeAttachment(id) }, + pruneAttachments: (ids) => { this.pruneAttachments(ids) }, submit: () => { this.submit('queue') }, } @@ -150,24 +150,24 @@ export class SessionInputShell implements SessionInput { private readonly unregister: () => void private noticeSeq = 0 private lastMirroredDraft = '' - private imageIds: readonly DraftAttachmentId[] = [] + private attachmentIds: readonly DraftAttachmentId[] = [] private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined - /** Default sends retained until admission settles or scope disposal releases their images. */ + /** Default sends retained until admission settles or scope disposal releases their attachments. */ private readonly detachedDrafts = new Map() /** Failed default sends waiting to be restored together in submission order. */ private readonly failedDetached = new Map() /** Revision of the last automatic failure restoration. */ private failedRestoreRev: number | undefined private restoringFailures = false - private imageFlightSeq = 0 - /** Image-only sends retained until admission settles or scope disposal releases their images. */ - private readonly imageFlights = new Map() constructor(private readonly deps: SessionInputDeps) { @@ -281,37 +281,38 @@ export class SessionInputShell implements SessionInput { }, { discrete: true, tag: HISTORY_MERGE_TAG }) } - /** Append ordered image ids unless an admission transaction is locked. */ - addImages(ids: readonly DraftAttachmentId[]): boolean { + /** Append ordered attachment ids unless an admission transaction is locked. */ + addAttachments(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.attachmentIds = [...this.attachmentIds, ...ids] this.publish() return true } /** - * Remove one image id from this draft. Busy admission phases refuse, like - * {@link addImages}: a removal landing while a command submit serializes + * Remove one attachment id from this draft. Busy admission phases refuse, like + * {@link addAttachments}: a removal landing while a command submit serializes * would otherwise vanish from the rail yet still ride the in-flight send. */ - removeImage(id: DraftAttachmentId): void { - if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return - const next = this.imageIds.filter(candidate => candidate !== id) - if (next.length === this.imageIds.length) return - this.imageIds = next + removeAttachment(id: DraftAttachmentId): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + const next = this.attachmentIds.filter(candidate => candidate !== id) + if (next.length === this.attachmentIds.length) return false + this.attachmentIds = next this.publish() + return true } /** - * Keep only image ids that still resolve in the browser attachment registry. + * Keep only ids that still resolve in the browser attachment registry. * @param available - live registry ids. */ - pruneImages(available: readonly DraftAttachmentId[]): void { + pruneAttachments(available: readonly DraftAttachmentId[]): void { const keep = new Set(available) - const next = this.imageIds.filter(id => keep.has(id)) - if (next.length === this.imageIds.length) return - this.imageIds = next + const next = this.attachmentIds.filter(id => keep.has(id)) + if (next.length === this.attachmentIds.length) return + this.attachmentIds = next this.publish() } @@ -319,11 +320,11 @@ export class SessionInputShell implements SessionInput { * Clear the draft as a successful-send commit: the editor empties (no undo * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent * content (the command path gets the same discipline from submit-settled). - * @param imageIds - admitted image ids to remove from this draft. + * @param attachmentIds - admitted attachment ids to remove from this draft. */ - commitSend(imageIds: readonly DraftAttachmentId[]): void { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + commitSend(attachmentIds: readonly DraftAttachmentId[]): void { + const submitted = new Set(attachmentIds) + this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id)) this.dispatchRun(({ type: 'send-committed' })) } @@ -358,34 +359,34 @@ export class SessionInputShell implements SessionInput { * dismisses and the menu tracks frozen. */ submit(mode: InputSubmitMode = 'queue'): void { - if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { + if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) { if (this.snapshot.phase === 'plain') { - const imageIds = [...this.imageIds] + const attachmentIds = [...this.attachmentIds] const controller = new AbortController() - this.imageFlightSeq += 1 - const flight = this.imageFlightSeq - this.imageFlights.set(flight, { controller, imageIds }) - this.commitSend(imageIds) - void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => { - if (this.disposed || !this.imageFlights.delete(flight)) return + this.attachmentFlightSeq += 1 + const flight = this.attachmentFlightSeq + this.attachmentFlights.set(flight, { controller, attachmentIds }) + this.commitSend(attachmentIds) + void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => { + if (this.disposed || !this.attachmentFlights.delete(flight)) return if (outcome.kind === 'success') return - this.restoreImages(imageIds) + this.restoreAttachments(attachmentIds) if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - if (this.disposed || !this.imageFlights.delete(flight)) return - this.restoreImages(imageIds) + if (this.disposed || !this.attachmentFlights.delete(flight)) return + this.restoreAttachments(attachmentIds) this.notify('error', error instanceof Error ? error.message : String(error)) }) } return } - // Claimed pre-gate: a claim that does not declare image acceptance never - // submits while images are attached — one notice, everything retained. + // Claimed pre-gate: a claim that does not declare attachment acceptance never + // submits while attachments are present — one notice, everything retained. // Enter-time adjudication applies the same policy for unclaimed lines // inside the command source itself. const before = this.snapshot - if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) { - this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) + if (before.phase === 'claimed' && this.attachmentIds.length > 0 && before.claim?.attachments !== true) { + this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft)) return } this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) @@ -562,18 +563,18 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- /** - * Teardown the shell and return every browser-owned image still retained by + * Teardown the shell and return every browser-owned attachment still retained by * the draft or an unsettled default send. - * @returns image ids the scope disposer must release. + * @returns attachment ids the scope disposer must release. */ dispose(): readonly DraftAttachmentId[] { if (this.disposed) return [] - const retained = new Set(this.imageIds) + const retained = new Set(this.attachmentIds) for (const record of this.detachedDrafts.values()) { - for (const imageId of record.imageIds) retained.add(imageId) + for (const attachmentId of record.attachmentIds) retained.add(attachmentId) } - for (const flight of this.imageFlights.values()) { - for (const imageId of flight.imageIds) retained.add(imageId) + for (const flight of this.attachmentFlights.values()) { + for (const attachmentId of flight.attachmentIds) retained.add(attachmentId) flight.controller.abort() } this.disposed = true @@ -582,7 +583,7 @@ export class SessionInputShell implements SessionInput { this.editor.setRootElement(null) this.detachedDrafts.clear() this.failedDetached.clear() - this.imageFlights.clear() + this.attachmentFlights.clear() return [...retained] } @@ -687,17 +688,17 @@ export class SessionInputShell implements SessionInput { draft: string, mode: InputSubmitMode, ): void { - const imageIds = [...this.imageIds] - this.imageIds = [] + const attachmentIds = [...this.attachmentIds] + this.attachmentIds = [] const occurrences = this.projection.occurrences - const record = { draft, occurrences, imageIds } + const record = { draft, occurrences, attachmentIds } this.detachedDrafts.set(attempt.seq, record) if (this.failedRestoreRev === this.rev) { this.failedDetached.clear() this.failedRestoreRev = undefined } if (occurrences.length === 0) { - this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal)) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal)) return } const inputTriggers = this.deps.inputTriggers?.() @@ -721,7 +722,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal)) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal)) }, (error: unknown) => { if (this.dead(attempt)) return @@ -758,7 +759,7 @@ export class SessionInputShell implements SessionInput { const record = this.detachedDrafts.get(attempt.seq) if (record === undefined) return this.detachedDrafts.delete(attempt.seq) - this.restoreImages(record.imageIds) + this.restoreAttachments(record.attachmentIds) this.failedDetached.set(attempt.seq, record) if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) { this.restoreFailedDrafts() @@ -821,13 +822,13 @@ export class SessionInputShell implements SessionInput { } } - /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ - private restoreImages(imageIds: readonly DraftAttachmentId[]): void { - if (imageIds.length === 0) return - const current = new Set(this.imageIds) - const restored = imageIds.filter(id => !current.has(id)) + /** Return failed-send attachments to the head of the rail; release happens only after success. */ + private restoreAttachments(attachmentIds: readonly DraftAttachmentId[]): void { + if (attachmentIds.length === 0) return + const current = new Set(this.attachmentIds) + const restored = attachmentIds.filter(id => !current.has(id)) if (restored.length === 0) return - this.imageIds = [...restored, ...this.imageIds] + this.attachmentIds = [...restored, ...this.attachmentIds] this.publish() } @@ -839,7 +840,7 @@ export class SessionInputShell implements SessionInput { this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined })) return } - inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( + inputTriggers.adjudicate(draft.trim(), attempt.signal, { attachments: this.attachmentIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return this.dispatchRun(({ type: 'adjudicated', attempt, outcome })) @@ -855,27 +856,27 @@ export class SessionInputShell implements SessionInput { /** * The submit transaction: claim.submit against the session scope; ok maps * from the outcome kind. An accepting claim receives the serialized draft - * images, which are cleared and released only on a success outcome; a - * failure (serialize, transport, or handler error) keeps draft and images + * attachments, which are cleared and released only on a success outcome; a + * failure (serialize, transport, or handler error) keeps draft and attachments * for correction. */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { - const imageIds = claim.images === true ? [...this.imageIds] : [] + const attachmentIds = claim.attachments === true ? [...this.attachmentIds] : [] Promise.resolve() .then(async () => { - const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : [] + const attachments = attachmentIds.length > 0 ? await this.deps.commandAttachments.serialize(attachmentIds) : [] // Serialization may outlive the attempt (large files, session // teardown); a dead attempt must not reach the Host executor. if (this.dead(attempt)) return undefined - return claim.submit(args, this.deps.actx, images) + return claim.submit(args, this.deps.actx, attachments) }) .then( (outcome) => { if (outcome === undefined || this.dead(attempt)) return - if (outcome.kind === 'success' && imageIds.length > 0) { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) - this.deps.commandImages.release(imageIds) + if (outcome.kind === 'success' && attachmentIds.length > 0) { + const submitted = new Set(attachmentIds) + this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id)) + this.deps.commandAttachments.release(attachmentIds) } this.dispatchRun(({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', @@ -903,7 +904,7 @@ export class SessionInputShell implements SessionInput { const core = this.core.state return { draft: this.projection.clipboardText, - imageIds: this.imageIds, + attachmentIds: this.attachmentIds, draftRev: this.rev, phase: core.phase, ...(core.claim !== undefined ? { claim: core.claim } : {}), diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 6c7132425e..00730b15f7 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -15,8 +15,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from './queue-store.ts' import type { - ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput, - SubmitImageAttachment, SubmitOutcome, + ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController, + SessionInputResolver, SessionInput, SubmitOutcome, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' @@ -38,12 +38,12 @@ interface ConversationAttachmentFace { sendSession( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal?: AbortSignal, ): Promise - serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise - releaseDraftImage(id: DraftAttachmentId): void + serializeDraftAttachments(attachmentIds: readonly DraftAttachmentId[]): Promise + releaseDraftAttachment(id: DraftAttachmentId): void } /** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */ @@ -88,19 +88,22 @@ export class InputHub implements SessionInputResolver { inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), + defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, - commandImages: { - serialize: ids => this.conversation().serializeDraftImages(ids), + commandAttachments: { + serialize: async (ids) => { + const result = await this.conversation().serializeDraftAttachments(ids) + return result.attachments + }, // Asymmetric with serialize on purpose: release settles AFTER the // submit RPC, where session teardown may already have unloaded the // conversation service (the same tolerance as the scope disposer // above); leaked preview URLs then die with the document. release: (ids) => { const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const imageId of ids) conversation?.releaseDraftImage(imageId) + for (const attachmentId of ids) conversation?.releaseDraftAttachment(attachmentId) }, - unsupportedNotice: token => this.t('command.imagesUnsupported', { + unsupportedNotice: token => this.t('command.attachmentsUnsupported', { command: token.trim().replace(/^\//u, ''), }), }, @@ -124,7 +127,7 @@ export class InputHub implements SessionInputResolver { const drafts = shell.dispose() this.shells.delete(id) const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const imageId of drafts) conversation?.releaseDraftImage(imageId) + for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId) } }, 'conversation.input: session shell') return shell @@ -175,12 +178,12 @@ export class InputHub implements SessionInputResolver { private sink( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal: AbortSignal, ): Promise { - if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' }) - return this.conversation().sendSession(session, text, imageIds, mode, signal) + if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' }) + return this.conversation().sendSession(session, text, attachmentIds, mode, signal) } /** diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index cfc757d63b..899da6f996 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -55,7 +55,7 @@ export class SubmitMachine { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}), - ...(c.images === true ? { images: true } : {}), + ...(c.attachments === true ? { attachments: true } : {}), }, } : {}), @@ -216,7 +216,7 @@ export class SubmitMachine { return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }] } - /** Clear after an accepted image-only send; it has no text suffix to retain. */ + /** Clear after an accepted attachment-only send; it has no text suffix to retain. */ private onSendCommitted(): readonly InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 296f95b33a..60f87f33ac 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,15 +23,16 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', - 'image.dropTitle': '图片拖动到此处即可添加', - 'image.dropDesc': '最多 {count} 张,每张 {size}', - 'image.dropBlocked': '当前无法添加图片', + 'attachment.pending': '待发送附件', + 'attachment.scrollLeft': '向左滚动附件', + 'attachment.scrollRight': '向右滚动附件', + 'attachment.dropTitle': '文件或图片拖动到此处即可添加', + 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', + 'attachment.dropBlocked': '当前无法添加文件或图片', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', 'image.remove': '移除图片 {name}', - 'image.scrollLeft': '向左滚动图片', - 'image.scrollRight': '向右滚动图片', 'image.original': '原图', 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', @@ -46,6 +47,16 @@ export const zh = { 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', + 'file.attach': '添加附件', + 'file.pending': '待发送文件', + 'file.remove': '移除文件 {name}', + 'file.uploading': '上传中…', + 'file.uploadFailed': '上传失败,点击重试', + 'file.retry': '重试上传 {name}', + 'file.stillUploading': '文件还在上传,请等待上传完成后发送', + 'file.sessionUnavailable': '会话不可用,无法上传文件', + 'file.notStaged': '文件尚未上传成功,请重新添加后再试', + 'file.label': '文件', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', 'context.system': '系统提示词', @@ -73,7 +84,7 @@ export const zh = { 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', - 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', @@ -131,6 +142,7 @@ export const zh = { 'details.running': '运行中…', 'queue.count': '{n} 条排队消息', 'queue.image': '排队消息图片', + 'queue.file': '排队文件 {name}', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', 'queue.save': '保存排队消息', @@ -173,15 +185,16 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', - 'image.dropTitle': 'Drag images here to add them', - 'image.dropDesc': 'Up to {count} images, {size} each', - 'image.dropBlocked': 'Images cannot be added right now', + 'attachment.pending': 'Pending attachments', + 'attachment.scrollLeft': 'Scroll attachments left', + 'attachment.scrollRight': 'Scroll attachments right', + 'attachment.dropTitle': 'Drag files or images here to add them', + 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', + 'attachment.dropBlocked': 'Files and images cannot be added right now', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', 'image.remove': 'Remove image {name}', - 'image.scrollLeft': 'Scroll images left', - 'image.scrollRight': 'Scroll images right', 'image.original': 'Original image', 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', @@ -196,6 +209,16 @@ export const en = { 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', + 'file.attach': 'Add attachment', + 'file.pending': 'Pending files', + 'file.remove': 'Remove file {name}', + 'file.uploading': 'Uploading…', + 'file.uploadFailed': 'Upload failed; click to retry', + 'file.retry': 'Retry uploading {name}', + 'file.stillUploading': 'Files are still uploading; send after they finish', + 'file.sessionUnavailable': 'Session unavailable; files cannot be uploaded', + 'file.notStaged': 'The file has not finished uploading; re-add it and try again', + 'file.label': 'File', 'context.aria': '{percent} of context used', 'context.used': 'of context used', 'context.system': 'System prompt', @@ -223,7 +246,7 @@ export const en = { 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', - 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', @@ -281,6 +304,7 @@ export const en = { 'details.running': 'Running…', 'queue.count': '{n} queued messages', 'queue.image': 'Queued message image', + 'queue.file': 'Queued file {name}', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', 'queue.save': 'Save queued message', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 81ea205b5e..e8434713bb 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -122,10 +122,51 @@ box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1); } -.thumbs { +.attachments { display: flex; flex: none; gap: 4px; + min-width: 0; + max-width: 55%; + overflow: hidden; +} + +.file { + display: inline-flex; + flex: 0 1 180px; + align-items: center; + gap: 4px; + min-width: 74px; + height: 24px; + padding: 0 6px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 6px; + background: var(--dsw-alias-bg-base); + box-sizing: border-box; +} + +.fileIcon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} + +.fileName { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-primary-dimmed); + font: var(--dsw-font-xs-13); + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileSize { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + white-space: nowrap; } .thumb { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 2840268034..abafa2bd7e 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,11 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, + DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -26,12 +27,36 @@ export interface QueueDockInjected { * @param content - the row's wire content blocks. * @returns the row's durable image references in block order. */ -function queueImageRefs(content: QueueRow['content']): ImageAttachmentRef[] { - return content.flatMap((block) => { - if (block.type !== 'image') return [] - const { attachment } = block as { attachment?: ImageAttachmentRef } - return attachment === undefined ? [] : [attachment] - }) +function queueAttachments(content: QueueRow['content']): Array< + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +> { + const attachments: Array< + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + > = [] + for (const block of content) { + if (block.type === 'image') { + const { attachment } = block as { attachment?: ImageAttachmentRef } + if (attachment !== undefined) attachments.push({ type: 'image', attachment }) + } + if (block.type === 'file') { + const { attachment } = block as { attachment?: FileAttachmentRef } + if (attachment !== undefined) attachments.push({ type: 'file', attachment }) + } + } + return attachments +} + +/** Compact file identity used beside queue thumbnails. */ +function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { + return ( + + + {attachment.name} + {fileSizeText(attachment.bytes)} + + ) } /** One durable queued image as a fixed-size thumbnail; a load failure keeps the empty placeholder. */ @@ -137,7 +162,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que )}
diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 27a4c3e629..2070744c4a 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -18,11 +18,15 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -import type { ComposerAttachment } from './contract/slots.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { + ComposerAttachment, ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, +} from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' import type { ComposerBlocks } from './contract/composer-blocks.ts' import type { - DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, + DraftAttachmentId, DraftAttachmentSerializationResult, SessionInputResolver, SubmitAttachment, SubmitOutcome, } from './contract/input.ts' import type { InputSubmitMode } from './contract/composer-submission.ts' @@ -64,8 +68,8 @@ export interface IConversation { loadOlder(): Promise } -/** Create one browser-only draft descriptor; only its id enters input state. */ -function browserDraftAttachment(file: File): ComposerAttachment { +/** Create one browser-only image draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerImageAttachment { return { kind: 'image', id: randomUUID() as DraftAttachmentId, @@ -82,7 +86,7 @@ function browserDraftAttachment(file: File): ComposerAttachment { * reads the dimensions into an immutable echo snapshot, so this late write * does not require a store notification. */ -function probeDimensions(attachment: ComposerAttachment): void { +function probeDimensions(attachment: ComposerImageAttachment): void { if (typeof Image !== 'function') return const probe = new Image() probe.onload = () => { @@ -115,8 +119,8 @@ function nextPaint(): Promise { }) } -/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ -function base64Of(file: File): Promise { +/** Native canonical base64 of one browser image (FileReader data-URL encode; no main-thread byte loop). */ +function base64ImageOf(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { @@ -149,7 +153,20 @@ export class ConversationController extends Service implements IConversation { readonly input: SessionInputResolver /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks + /** Live upload state per file-kind draft; images never appear here. */ + readonly fileUploads: SnapshotStore> = createSnapshotStore>({}) private readonly draftAttachments = new Map() + private readonly fileUploadOperations = new Map + }>() + private readonly pendingFileUploads = new Set>() + private readonly fileUploadQueue: Array<{ + readonly run: () => Promise + readonly settle: () => void + }> = [] + private activeFileUploads = 0 + private readonly maxConcurrentFileUploads: number /** * @param ctx - owning root context (the plugin apply context; the service @@ -158,15 +175,26 @@ export class ConversationController extends Service implements IConversation { * constructed by the plugin apply (the same instances the slot inject * factories close over). */ - constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) { + constructor(ctx: Context, config: { + input: SessionInputResolver + blocks: ComposerBlocks + maxConcurrentFileUploads: number + }) { super(ctx, 'conversation') this.input = config.input this.blocks = config.blocks - ctx.effect(() => () => { + this.maxConcurrentFileUploads = config.maxConcurrentFileUploads + ctx.effect(() => async () => { + const operations = [...this.fileUploadOperations.values()] + for (const operation of operations) operation.controller.abort() + await Promise.allSettled([...this.pendingFileUploads]) + this.fileUploadOperations.clear() + this.fileUploadQueue.length = 0 for (const attachment of this.draftAttachments.values()) { - revokePreview(attachment.previewUrl) + if (attachment.kind === 'image') revokePreview(attachment.previewUrl) } this.draftAttachments.clear() + this.fileUploads.set({}) }, 'conversation draft attachments') } @@ -183,15 +211,15 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. A local + * Submit ordered draft attachments with text through one host admission. A local * submission echo enters the session snapshot synchronously; serialization * and the prompt round-trip start after the browser can paint it. On the - * echo's observed retirement the draft images hand their preview URLs to - * the durable image cache and leave the registry; on failure they stay - * registered so the composer can restore them. + * echo's observed retirement seeds admitted image previews into the durable + * cache and removes every attachment from the draft registry. On failure, + * every attachment remains registered so the composer can restore it. * @param session - target session. * @param text - serialized prompt text. - * @param imageIds - ordered draft-local attachment ids. + * @param attachmentIds - ordered draft-local attachment ids. * @param mode - queue or steer delivery selected by composer policy. * @param signal - optional cancellation for the complete Host admission. * @returns the Host admission outcome; local attachment preparation failures reject. @@ -199,17 +227,39 @@ export class ConversationController extends Service implements IConversation { async sendSession( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal?: AbortSignal, ): Promise { - const attachments = this.draftImages(imageIds) - if (attachments.length !== imageIds.length) { - throw new Error('conversation.sendSession: one or more draft images are no longer available') + const attachments = this.resolveDraftAttachments(attachmentIds) + if (attachments.length !== attachmentIds.length) { + throw new Error('conversation.sendSession: one or more draft attachments are no longer available') } + const uploads = this.fileUploads.getSnapshot() + const uploadFor = (attachment: ComposerFileAttachment): Extract => { + const upload = uploads[attachment.id] + if (upload === undefined || upload.status !== 'ready') { + throw new Error('conversation.sendSession: one or more files have not finished uploading') + } + return upload + } + const pendingAttachments = attachments.map(attachment => attachment.kind === 'image' + ? { + type: 'image' as const, + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + } + : { type: 'file' as const, attachment: uploadFor(attachment).file }) + const serializeAttachments = (): Promise[0]> => Promise.all( + attachments.map(async attachment => attachment.kind === 'image' + ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } + : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), + ) const snapshot = session.getSnapshot() if (snapshot.subagent !== null) { - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const uploaded = await serializeAttachments() const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode, signal) return result.ok ? { kind: 'success' } : { kind: 'error' } @@ -221,21 +271,16 @@ export class ConversationController extends Service implements IConversation { const submission = session.beginSubmission({ mode, text, - images: attachments.map(attachment => ({ - previewUrl: attachment.previewUrl, - ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), - ...(attachment.width === undefined ? {} : { width: attachment.width }), - ...(attachment.height === undefined ? {} : { height: attachment.height }), - })), + attachments: pendingAttachments, onRetire: (settlement) => { - this.settleSubmittedImages(session.sessionId, attachments, settlement) + this.settleSubmittedAttachments(session.sessionId, attachments, settlement) finishRetirement?.(settlement) }, }) let content: Parameters[0] try { await nextPaint() - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const uploaded = await serializeAttachments() content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] } catch (error) { submission.abandon() @@ -248,26 +293,135 @@ export class ConversationController extends Service implements IConversation { } /** - * Create runtime-only draft images and their object URLs. - * @param files - browser files to register after MIME validation. + * Create runtime-only draft attachments. Files whose browser MIME is an + * accepted image type become image drafts (object URL preview, bytes sent + * with the prompt); every other file becomes a file draft whose background + * upload starts immediately and remains owned by this service across Session + * navigation until completion or explicit removal. + * @param session - target session owning staged file uploads. + * @param files - browser files to register. * @returns ordered draft descriptors. */ - createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { - for (const file of files) imageMediaType(file.type) + createDrafts(session: SessionFace, files: readonly File[]): readonly ComposerAttachment[] { return files.map((file) => { - const attachment = browserDraftAttachment(file) + if (isImageMediaType(file.type)) { + const attachment = browserDraftAttachment(file) + this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) + return attachment + } + const attachment: ComposerFileAttachment = { + kind: 'file', + id: randomUUID() as DraftAttachmentId, + file, + } this.draftAttachments.set(attachment.id, attachment) - probeDimensions(attachment) + this.beginFileUpload(session, attachment) return attachment }) } /** - * Resolve ordered input-state ids to runtime-owned draft images. + * Restart one failed file upload. + * @param session - target session owning staged file uploads. + * @param id - draft attachment id whose upload previously failed. + */ + retryFileUpload(session: SessionFace, id: DraftAttachmentId): void { + const attachment = this.draftAttachments.get(id) + if (attachment === undefined || attachment.kind !== 'file') return + if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return + this.beginFileUpload(session, attachment) + } + + /** + * Stage carried file drafts again for a new Session. + * @param session - target Session after a Workspace switch. + * @param ids - carried draft attachment ids. + */ + rebindDraftFiles(session: SessionFace, ids: readonly DraftAttachmentId[]): void { + for (const id of ids) { + const attachment = this.draftAttachments.get(id) + if (attachment?.kind === 'file') this.beginFileUpload(session, attachment) + } + } + + private beginFileUpload(session: SessionFace, attachment: ComposerFileAttachment): void { + this.fileUploadOperations.get(attachment.id)?.controller.abort() + const controller = new AbortController() + this.fileUploads.update((draft) => { + draft[attachment.id] = { status: 'uploading', loaded: 0 } + }) + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + this.fileUploadOperations.set(attachment.id, { controller, done }) + this.pendingFileUploads.add(done) + void done.then(() => { this.pendingFileUploads.delete(done) }) + const run = async (): Promise => { + try { + if (controller.signal.aborted + || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + const result = await session.uploadFile( + attachment.file, + attachment.file.name === '' ? undefined : attachment.file.name, + controller.signal, + (progress) => { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'uploading', + loaded: progress.loaded, + ...(progress.total === undefined ? {} : { total: progress.total }), + } + }) + }, + ) + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = result.ok + ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + : { status: 'error', message: result.error.message } + }) + } catch (error) { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'error', + message: error instanceof Error ? error.message : String(error), + } + }) + } finally { + if (this.fileUploadOperations.get(attachment.id)?.controller === controller) { + this.fileUploadOperations.delete(attachment.id) + } + } + } + this.fileUploadQueue.push({ run, settle }) + this.pumpFileUploads() + } + + /** Start queued upload Workers until the configured concurrency is occupied. */ + private pumpFileUploads(): void { + while (this.activeFileUploads < this.maxConcurrentFileUploads) { + const task = this.fileUploadQueue.shift() + if (task === undefined) return + this.activeFileUploads += 1 + void task.run().finally(() => { + this.activeFileUploads -= 1 + task.settle() + this.pumpFileUploads() + }) + } + } + + /** + * Resolve ordered input-state ids to runtime-owned draft attachments. * @param ids - draft attachment ids. * @returns descriptors that remain live, in requested order. */ - draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { + resolveDraftAttachments(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { const attachments: ComposerAttachment[] = [] for (const id of ids) { const attachment = this.draftAttachments.get(id) @@ -277,37 +431,59 @@ export class ConversationController extends Service implements IConversation { } /** - * Serialize ordered draft images to command-submit wire payloads without - * sending or releasing them (the composer releases only after the command - * settles successfully). - * @param imageIds - ordered draft-local attachment ids. - * @returns base64 payloads in id order. + * Serialize ordered draft attachments to command-submit wire payloads without + * sending or releasing them. Images are encoded; generic files cite receipts + * from their completed background uploads and never reread browser bytes. + * @param attachmentIds - ordered draft-local attachment ids. + * @returns wire payloads in id order. */ - async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise { - const attachments = this.draftImages(imageIds) - if (attachments.length !== imageIds.length) { - throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available') + async serializeDraftAttachments( + attachmentIds: readonly DraftAttachmentId[], + ): Promise { + const attachments = this.resolveDraftAttachments(attachmentIds) + if (attachments.length !== attachmentIds.length) { + throw new Error('conversation.serializeDraftAttachments: one or more draft attachments are no longer available') + } + const uploads = this.fileUploads.getSnapshot() + return { + attachments: await Promise.all(attachments.map(async (attachment) => { + if (attachment.kind === 'image') return { type: 'image' as const, ...await this.encodeImage(attachment.file) } + const upload = uploads[attachment.id] + if (upload === undefined || upload.status !== 'ready') { + throw new Error('conversation.serializeDraftAttachments: one or more files have not finished uploading') + } + return { type: 'file' as const, receiptId: upload.receiptId } + })), } - return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file))) } /** - * Release one browser-owned draft image and preview URL. + * Release one browser-owned draft attachment, aborting its active upload. * @param id - draft attachment id. */ - releaseDraftImage(id: DraftAttachmentId): void { + releaseDraftAttachment(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return + const operation = this.fileUploadOperations.get(id) + this.fileUploadOperations.delete(id) + operation?.controller.abort() this.draftAttachments.delete(id) - revokePreview(attachment.previewUrl) + if (attachment.kind === 'image') { + revokePreview(attachment.previewUrl) + return + } + // The stored Host object stays durable; only the draft's upload state ends. + this.fileUploads.set(Object.fromEntries( + Object.entries(this.fileUploads.getSnapshot()).filter(([key]) => key !== id), + )) } /** - * Release a set of browser-owned draft images. + * Release a set of browser-owned draft attachments. * @param attachments - descriptors to release. */ - releaseDraftImages(attachments: readonly ComposerAttachment[]): void { - for (const attachment of attachments) this.releaseDraftImage(attachment.id) + releaseDraftAttachments(attachments: readonly ComposerAttachment[]): void { + for (const attachment of attachments) this.releaseDraftAttachment(attachment.id) } /** Apply one operation to a pending queue occurrence. */ @@ -361,40 +537,41 @@ export class ConversationController extends Service implements IConversation { } /** - * Settle one submission's draft images when its echo retires. Observed: + * Settle one submission's draft attachments when its echo retires. Observed: * each image leaves the registry, handing its preview URL to the durable * image cache (seeded under the admitted reference so the transcript node * renders immediately while the cache reads canonical bytes) or revoking it * when the cache already holds that reference. Failed: nothing changes; * the ids stay registered for the composer's rail restore. */ - private settleSubmittedImages( + private settleSubmittedAttachments( sessionId: SessionId, attachments: readonly ComposerAttachment[], retirement: PendingSubmissionRetirement, ): void { if (retirement.reason !== 'observed') return const uiConversation = this.ctx.get('uiConversation') - attachments.forEach((attachment, index) => { + let observedIndex = 0 + for (const attachment of attachments) { const live = this.draftAttachments.get(attachment.id) - if (live === undefined) return + const ref = retirement.attachments[observedIndex++] + if (live === undefined) continue + if (attachment.kind === 'file') { + this.releaseDraftAttachment(attachment.id) + continue + } this.draftAttachments.delete(attachment.id) - const ref = retirement.attachments[index] - if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + if (ref !== undefined && 'mediaType' in ref + && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue revokePreview(attachment.previewUrl) - }) - } - - /** Convert browser files to canonical base64 prompt parts. */ - private serializeImages(images: readonly File[]): Promise[0]> { - return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) + } } /** Canonical base64 wire form of one browser image file. */ - private async encodeImage(file: File): Promise { + private async encodeImage(file: File): Promise, 'type'>> { return { mediaType: imageMediaType(file.type), - data: await base64Of(file), + data: await base64ImageOf(file), ...(file.name === '' ? {} : { name: file.name }), } } @@ -412,6 +589,11 @@ function imageMediaType(value: string): ImageMediaType { } } +/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */ +function isImageMediaType(value: string): boolean { + return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' +} + function revokePreview(url: string): void { if (url.startsWith('blob:')) URL.revokeObjectURL(url) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6c8c46cad4..25dcbef1ce 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -14,10 +14,10 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import type { CSSProperties, ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -40,9 +40,10 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages, + useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + retryFileUpload, resolveSubmitMode, toggleCommandMenu, stop, command, t, - renderSlot, useNotices, useLexicon, useMenuLauncher, + renderSlot, useFileUploads, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, overlay, leftItems, rightItems, footer, @@ -66,10 +67,16 @@ export function InputBar({ const draft = input?.draft ?? '' const editor = keyboard?.editor ?? null const attachments = useMemo( - () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds), - [draftImages, input?.imageIds], + () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input?.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 + const uploads = useFileUploads(snapshot => snapshot) + // Send waits for every picked file: uploading and failed drafts both hold + // the gate (a failed upload is retried or removed, never silently dropped). + const uploadsPending = attachments.some( + attachment => attachment.kind === 'file' && uploads[attachment.id]?.status !== 'ready', + ) // Transient error banner (machine notices, image-intake rejections, and // prompt failures): the seq keys the Toast so an identical repeated message // restarts the hold-then-fade cycle instead of reusing the faded one. @@ -136,10 +143,10 @@ export function InputBar({ useEffect(() => { if (input === undefined || inputActions === undefined) return - if (attachments.length !== input.imageIds.length) { - inputActions.pruneImages(attachments.map(attachment => attachment.id)) + if (attachments.length !== input.attachmentIds.length) { + inputActions.pruneAttachments(attachments.map(attachment => attachment.id)) } - }, [attachments, input?.imageIds, inputActions]) + }, [attachments, input?.attachmentIds, inputActions]) // Scroll the draft scrollport the minimum that brings the selection focus // into view — the browser's own behavior for typing, performed for the @@ -212,46 +219,56 @@ export function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) - // Intake pre-check: an addition that would break - // a projected limit is refused as a whole batch, announced immediately, and - // never enters the rail — no more submit-time failure rolling the rail - // back. The host enforces the same limits at submit for callers that bypass + // Intake pre-check: an addition that would break a projected image limit is + // refused as a whole batch, announced immediately, and never enters the + // rail. Only the image subset is limit-checked: generic files carry no + // client-side size or count limit and upload as soon as they are picked. + // The host enforces the same image limits at submit for callers that bypass // this composer. - const intakeImages = useCallback((files: readonly File[]): void => { - if (addImages === undefined || files.length === 0) return + const intakeFiles = useCallback((files: readonly File[]): void => { + if (subagent !== null || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { - // Format precedes limits: a batch with - // a non-image must announce the format problem, not a count or size - // it could never pass anyway — addImages rejects it authoritatively. - if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) { - return addImages(files) - } - if (attachments.length + files.length > imageLimits.maxImagesPerMessage) { + const mediaTypes = imageLimits.mediaTypes as readonly string[] + const images = files.filter(file => mediaTypes.includes(file.type)) + const imageAttachments = attachments.filter(attachment => attachment.kind === 'image') + if (imageAttachments.length + images.length > imageLimits.maxImagesPerMessage) { return t('image.tooMany', { count: imageLimits.maxImagesPerMessage }) } - if (files.some(file => file.size > imageLimits.maxImageBytes)) { + if (images.some(file => file.size > imageLimits.maxImageBytes)) { return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) }) } - const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0) - + files.reduce((sum, file) => sum + file.size, 0) + const total = imageAttachments.reduce((sum, attachment) => sum + attachment.file.size, 0) + + images.reduce((sum, file) => sum + file.size, 0) if (total > imageLimits.maxMessageImageBytes) { return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) } } - return addImages(files) + return addFiles(files) })() if (rejected !== null) showToast(rejected) - }, [addImages, attachments, imageLimits, showToast, t]) + }, [subagent, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = !locked && !machineBusy && addImages !== undefined + const canAcceptDrop = subagent === null && !locked && !machineBusy && addFiles !== undefined + + const fileInputRef = useRef(null) + const onPickFiles = (e: ChangeEvent): void => { + const picked = e.target.files === null ? [] : [...e.target.files] + // Reset so picking the same file again re-fires the change event. + e.target.value = '' + if (picked.length > 0) intakeFiles(picked) + } // The keymap handlers read live bar state through this ref so the editor // registration survives re-renders without re-arming per keystroke. const gate = useRef({ - locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages, + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, + intakeFiles, uploadsPending, showToast, t, }) - gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages } + gate.current = { + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, + intakeFiles, uploadsPending, showToast, t, + } useEffect(() => { if (editor === null || keyboard === undefined) return @@ -272,13 +289,17 @@ export function InputBar({ keyboard.steerQueue() return } + if (g.uploadsPending) { + g.showToast(g.t('file.stillUploading')) + return + } keyboard.submit(g.resolveSubmitMode( g.running, accelerated ? 'accelerated' : 'enter', g.subagent === null, )) }, - intakeFiles: (files) => { gate.current.intakeImages(files) }, + intakeFiles: (files) => { gate.current.intakeFiles(files) }, pasteText: (text) => { if (gate.current.machineBusy || gate.current.locked) return keyboard.paste(text) @@ -321,8 +342,8 @@ export function InputBar({ return } if (inputActions === undefined) return // absent machine: the button is disabled - /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ - if (!empty && !disabled && !machineBusy) inputActions.submit() + /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ + if (!empty && !disabled && !machineBusy && !uploadsPending) inputActions.submit() } // The Access seat: the projection-fed permission chip (renders nothing @@ -396,8 +417,10 @@ export function InputBar({ {renderSlot('conversation.input.attachments', { attachments, canAcceptDrop, - onAddImages: intakeImages, - onRemoveImage: (id) => { removeImage?.(id) }, + onAddFiles: intakeFiles, + onRemoveAttachment: (id) => { removeAttachment?.(id) }, + uploads, + onRetryFile: (id) => { retryFileUpload?.(id) }, dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), @@ -450,6 +473,26 @@ export function InputBar({ + + + +
{accessSelect} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} @@ -481,7 +524,7 @@ export function InputBar({ type="button" className={css.primary} aria-label={primaryLabel} - disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy} + disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending} onMouseDown={keepFocus} onClick={onPrimary} > diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx new file mode 100644 index 0000000000..743a01a3ff --- /dev/null +++ b/packages/client/ui-primitives/src/DocumentFileIcon.tsx @@ -0,0 +1,41 @@ +import { useId } from 'react' + +/** + * Render the DeepSeek Web document glyph used by generic-file cards. + * @param props - optional CSS class for sizing and placement. + * @returns a decorative document SVG with an instance-safe gradient id. + */ +export function DocumentFileIcon({ className }: { readonly className?: string | undefined }) { + const gradientId = useId() + return ( + + + + + + + + + + + + ) +} diff --git a/packages/client/ui-primitives/src/file-size.ts b/packages/client/ui-primitives/src/file-size.ts new file mode 100644 index 0000000000..16d5ecacf9 --- /dev/null +++ b/packages/client/ui-primitives/src/file-size.ts @@ -0,0 +1,16 @@ +/** Compact human-readable byte counts shared by attachment presenters. @module @deepseek-ai/dsh-client-ui-primitives/file-size */ + +/** + * Byte count as compact user-facing size text (`312B`, `4.2KB`, `1.5MB`, `2.4GB`). + * @param bytes - exact byte count. + * @returns whole-unit text with one decimal below ten of the chosen unit. + */ +export function fileSizeText(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB` + const gb = mb / 1024 + return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}GB` +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c5f88e5eee..139b9f5fa4 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -32,6 +32,8 @@ export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' +export { fileSizeText } from './file-size.ts' +export { DocumentFileIcon } from './DocumentFileIcon.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index a811e915f2..d8076b5797 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -125,10 +125,15 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< const preview = previewContent(node.content) const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) - return { - text: previewMarkdown === undefined && images > 0 + const files = fileBlockCount(node.content) + const attachmentSummary = [ + previewMarkdown === undefined && images > 0 ? t('layout.imageOnly', { count: images }) - : '', + : undefined, + files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + return { + text: attachmentSummary, ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, @@ -848,6 +853,10 @@ function imageBlockCount(content: readonly { type: string }[]): number { return content.filter(block => block.type === 'image').length } +function fileBlockCount(content: readonly { type: string }[]): number { + return content.filter(block => block.type === 'file').length +} + function stringifySourceValue(value: unknown): string { const json = JSON.stringify(value, null, 2) return json || String(value) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index ddc5cea7c7..6bdc621413 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -174,6 +174,7 @@ export const zh = { 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', 'layout.imageOnly': '图片 ×{count}', + 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', 'layout.toolsUpdated': '工具已更新', @@ -366,6 +367,7 @@ export const en: Record = { 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', 'layout.imageOnly': 'Images ×{count}', + 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 'layout.toolsUpdated': 'Tools Updated', From e9b7ed0aab1591f4fe11bb91c1e9c45c0e51becd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:38:51 +0800 Subject: [PATCH 05/37] test: cover generic file submission flows --- .../command-image-envelope.expected.e2e.ts | 24 +- .../file-upload-round/draft.expected.md | 9 + .../file-upload-round/history.expected.md | 10 + .../conversation-expanded.expected.md | 2 + .../conversation.expected.md | 2 + .../goal-command-presentation/ui.expected.md | 2 + .../markdown-cjk-strong/ui.expected.md | 2 + .../expected/markdown-images/ui.expected.md | 2 + .../markdown-inline-code-links/ui.expected.md | 2 + .../expected/math-rendering/ui.expected.md | 2 + .../reference-composer/order.expected.md | 2 + .../skill-user-invoke/ui-expanded.expected.md | 2 + .../expected/skill-user-invoke/ui.expected.md | 2 + .../stats-paged-history/ui.expected.md | 2 + .../expected/steer-all/mid-steer.expected.md | 2 + .../steer-all/settled-expanded.expected.md | 2 + .../expected/steer-all/settled.expected.md | 2 + apps/web/tests/file-upload-round.e2e.ts | 283 ++++++++++++ apps/web/tests/image-display.expected.e2e.ts | 28 +- apps/web/tests/scaffold.ts | 42 +- .../mid-stream.expected.md | 2 + apps/web/tests/submission-echo.e2e.ts | 4 +- apps/web/tsconfig.json | 1 + .../tests/client-apply.client.spec.ts | 1 + .../tests/client-contract.client.spec.ts | 5 +- .../commands-queue-attachment.host.spec.ts | 17 + .../tests/commands-upload-file.host.spec.ts | 376 ++++++++++++++++ .../tests/controller.host.spec.ts | 26 +- .../tests/fake-api.client.ts | 8 + .../tests/file-upload-http.host.spec.ts | 143 ++++++ .../tests/queue-store.client.spec.ts | 18 +- .../tests/session-cold.host.spec.ts | 15 +- ...session-pending-submissions.client.spec.ts | 78 +++- .../tests/session.client.spec.ts | 144 ++++++ .../tests/background-upload.client.spec.ts | 193 ++++++++ .../tests/fetch-routes.host.spec.ts | 20 +- .../tests/fixture-commands.client.spec.ts | 12 +- .../connection/tests/http-bridge.host.spec.ts | 72 +++ .../tests/attachment-rail.client.spec.tsx | 33 +- .../composer-attachments.client.spec.tsx | 148 +++++- .../tests/message-image.client.spec.tsx | 13 +- .../tests/chat-branch-tails.client.spec.tsx | 26 ++ .../ui-chat/tests/chat-view.client.spec.tsx | 85 +++- .../tests/gate-branch-tails.client.spec.tsx | 12 +- .../ui-commands/tests/service.client.spec.ts | 66 +-- .../tests/apply-inject.client.spec.tsx | 47 +- .../conversation-registry.client.spec.ts | 1 + .../tests/input-bar.client.spec.tsx | 96 ++-- .../tests/input-matrix.client.spec.tsx | 72 +-- .../input-reference-submit.client.spec.ts | 38 +- .../tests/input-scenarios.client.spec.tsx | 30 +- .../tests/queue-dock.client.spec.tsx | 44 +- .../service-orchestration.client.spec.ts | 424 ++++++++++++++++-- .../tests/skeleton.client.spec.tsx | 10 +- .../tests/submit-machine.client.spec.ts | 4 +- .../tests/service.client.spec.ts | 10 +- .../tests/file-size.client.spec.ts | 14 + .../ui-tool/tests/diff-card.client.spec.tsx | 6 +- .../ui-tool/tests/read-card.client.spec.tsx | 6 +- .../ui-tool/tests/search-card.client.spec.tsx | 6 +- .../tests/terminal-card.client.spec.tsx | 10 +- .../ui-tool/tests/web-card.client.spec.tsx | 6 +- .../tests/layout.client.spec.tsx | 43 ++ .../ui-trajectory/tests/views.client.spec.tsx | 16 +- .../tests/plan-review-panel.client.spec.tsx | 8 +- .../user-questions-composer.client.spec.tsx | 8 +- .../tests/workflow-run.client.spec.tsx | 6 +- .../tests/port-selection.host.spec.ts | 56 ++- .../tests/transport/frames.spec.ts | 16 + .../tests/transport/tunnel-client.spec.ts | 9 + .../tests/transport/tunnel-server.spec.ts | 38 ++ .../extensions/tool-cordis/src/api-catalog.ts | 93 +++- .../command-goal/tests/command-goal.spec.ts | 45 +- .../commands/tests/commands.spec.ts | 86 +++- .../plan/plan-mode/tests/plan-mode.spec.ts | 32 +- .../subagent-codex/tests/real-product.spec.ts | 2 +- .../tests/runtime.client.spec.tsx | 1 + .../session-snapshot/tests/harness.spec.ts | 8 +- pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 8 + scripts/verify-client-ui-i18n.ts | 3 + snapshots/web/bash-abort-row/ui.expected.md | 2 + .../web/cordis-tool-round/ui.expected.md | 2 + .../feedback-command/ack-expanded.expected.md | 2 + .../web/feedback-command/ack.expected.md | 2 + .../feedback-release/ack-expanded.expected.md | 2 + .../web/feedback-release/ack.expected.md | 2 + .../file-upload-round/replay.override.json | 43 ++ snapshots/web/file-upload-round/session.jsonl | 31 ++ snapshots/web/file-upload-round/snapshot.yml | 9 + .../file-upload-round/trajectory.expected.md | 3 + .../web/file-upload-round/ui.expected.md | 52 +++ .../fresh-round-trip/ui-expanded.expected.md | 2 + snapshots/web/fresh-round-trip/ui.expected.md | 2 + .../ui-expanded.expected.md | 2 + .../goal-multi-turn-actions/ui.expected.md | 2 + .../web/lifecycle-chrome/hero.expected.md | 2 + .../lifecycle-chrome/plan-active.expected.md | 2 + .../reloaded-expanded.expected.md | 2 + .../web/lifecycle-chrome/reloaded.expected.md | 2 + .../cancel-expanded.expected.md | 2 + .../web/live-interactions/cancel.expected.md | 2 + .../live-interactions/error-auth.expected.md | 2 + .../web/live-interactions/loading.expected.md | 2 + .../retry-exhausted.expected.md | 2 + .../retry-expanded.expected.md | 2 + .../web/live-interactions/retry.expected.md | 2 + .../running-draft.expected.md | 2 + snapshots/web/message-actions/ui.expected.md | 2 + snapshots/web/minimal-preset/ui.expected.md | 2 + .../plan-review/approved-expanded.expected.md | 2 + .../web/plan-review/approved.expected.md | 2 + snapshots/web/ptc-round/ui.expected.md | 2 + .../answered-expanded.expected.md | 2 + .../question-composer/answered.expected.md | 2 + .../question-composer/cancelled.expected.md | 2 + .../web/queue-actions/collapsed.expected.md | 2 + .../web/queue-actions/editing.expected.md | 2 + .../web/queue-actions/layout.expected.md | 2 + .../preserved-expanded.expected.md | 2 + .../web/queue-actions/preserved.expected.md | 2 + snapshots/web/queue-actions/ui.expected.md | 2 + .../web/queued-image/delivered.expected.md | 2 + snapshots/web/queued-image/queued.expected.md | 2 + .../seeded-history/command-row.expected.md | 2 + .../seeded-history/feedback-row.expected.md | 2 + .../seeded-history/ui-expanded.expected.md | 2 + snapshots/web/seeded-history/ui.expected.md | 2 + snapshots/web/skill-tool-row/ui.expected.md | 2 + .../web/steering/settled-expanded.expected.md | 2 + snapshots/web/steering/settled.expected.md | 2 + .../ui-expanded.expected.md | 2 + .../web/subagent-conversation/ui.expected.md | 2 + .../offline-composer.expected.md | 2 + .../turn-tail-actions/completed.expected.md | 2 + .../web/turn-tail-actions/focused.expected.md | 2 + .../web/turn-tail-actions/running.expected.md | 2 + .../web/turn-tail-actions/settled.expected.md | 2 + .../usage-expanded.expected.md | 2 + snapshots/web/web-search-round/ui.expected.md | 2 + 140 files changed, 3061 insertions(+), 432 deletions(-) create mode 100644 apps/web/tests/expected/file-upload-round/draft.expected.md create mode 100644 apps/web/tests/expected/file-upload-round/history.expected.md create mode 100644 apps/web/tests/file-upload-round.e2e.ts create mode 100644 packages/api/session-controller/tests/commands-upload-file.host.spec.ts create mode 100644 packages/api/session-controller/tests/file-upload-http.host.spec.ts create mode 100644 packages/client/connection/tests/background-upload.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/file-size.client.spec.ts create mode 100644 snapshots/web/file-upload-round/replay.override.json create mode 100644 snapshots/web/file-upload-round/session.jsonl create mode 100644 snapshots/web/file-upload-round/snapshot.yml create mode 100644 snapshots/web/file-upload-round/trajectory.expected.md create mode 100644 snapshots/web/file-upload-round/ui.expected.md diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index 47fb91069b..f7da8f088a 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -// The command image-attachment envelope over the BUILT client graph (real +// The command attachment envelope over the BUILT client graph (real // bundles via AppWebEntry, keyless fixture Connection RPC): an enter -// submission carrying composer images resolves only through a command whose -// descriptor declares `input.images`. A non-declaring command refuses with +// submission carrying composer attachments resolves only through a command whose +// descriptor declares `input.attachments`. A non-declaring command refuses with // one composer error banner and everything retained; a declaring command // consumes the images — serialized through the real draft-image chain into // the commands/execute payload — and clears the composer on success, including @@ -47,7 +47,7 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise { }, }) await waitFor(() => { - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (rail === null) throw new Error('attachment rail missing') expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name) }, { timeout: 5_000 }) @@ -58,7 +58,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft const textarea = await freshComposer() await pasteImage(textarea, 'ref.png') - // /echo is a leadingInput fixture command without `input.images`. + // /echo is a leadingInput fixture command without `input.attachments`. await pasteText(textarea, '/echo hello') fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -66,16 +66,16 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft // failures; session activity remains on its separate status live region. const notice = await waitFor(() => { const el = [...document.querySelectorAll('[role="alert"]')] - .find(candidate => candidate.textContent?.includes('image attachments') ?? false) + .find(candidate => candidate.textContent?.includes('attachments') ?? false) if (el === undefined) throw new Error('composer refusal banner missing') return el }, { timeout: 5_000 }) - expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first') + expect(notice.textContent).toBe('/echo does not accept attachments; remove them first') expect([...document.querySelectorAll('[role="status"]')] - .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false) + .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false) // The whole envelope is retained: draft text and the rail thumbnail. await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') }) - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png']) }) @@ -84,14 +84,14 @@ it('consumes images through a declaring command and clears the composer on succe const textarea = await freshComposer() await pasteImage(textarea, 'goal-ref.png') - // /goal declares `input.images` in the fixture catalog; the claim submit + // /goal declares `input.attachments` in the fixture catalog; the claim submit // serializes the pasted bytes and the fixture executor admits them. await pasteText(textarea, '/goal rebuild the cathedral') fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => { expect(textarea.textContent).toBe('') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 5_000 }) }) @@ -107,7 +107,7 @@ it('submits a bare /plan with an image as an image-only plan request', async () await waitFor(() => { expect(textarea.textContent).toBe('') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 5_000 }) expect([...document.querySelectorAll('[role="alert"]')] .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false) diff --git a/apps/web/tests/expected/file-upload-round/draft.expected.md b/apps/web/tests/expected/file-upload-round/draft.expected.md new file mode 100644 index 0000000000..aa5ef30e82 --- /dev/null +++ b/apps/web/tests/expected/file-upload-round/draft.expected.md @@ -0,0 +1,9 @@ +# Mixed composer attachment rail + +- selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png +- one attachment group: true +- all cards share one row: true +- every card is 64px high: true +- the file card is wider than an image: true +- overflowing cards scroll horizontally: true +- the rail does not wrap: true diff --git a/apps/web/tests/expected/file-upload-round/history.expected.md b/apps/web/tests/expected/file-upload-round/history.expected.md new file mode 100644 index 0000000000..1427ef8004 --- /dev/null +++ b/apps/web/tests/expected/file-upload-round/history.expected.md @@ -0,0 +1,10 @@ +# Mixed history attachment flow + +- source order: poem.txt > reference-1.png +- one attachment group: true +- file and image share one row: true +- both cards are 64px high: true +- the image is a 64px tile: true +- the file card is wider than the image: true +- the group wraps when needed: true +- the group is right-aligned: true diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 3a29a14b51..e5e246ad19 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": - text: github-webhook-review-test/reply diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 0a5fe67b90..fa6bf3f25c 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": - text: github-webhook-review-test/reply diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index c10eef6aac..432a51de4d 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -17,6 +17,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 2fba524c5d..c9709a6386 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3843143d1f..f26dfd8214 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -30,6 +30,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 2d2127cbb2..41424d0ee1 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -42,6 +42,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index fba05ffe71..21023fdc64 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -46,6 +46,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7cec2eaf08..3ff4bf9c21 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -16,6 +16,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 07be7102ff..d55c6b0d35 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 7ddf071159..545e0c93b4 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index fc8039e282..9354a75fae 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -462,6 +462,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c3847df394..1456ffa683 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -34,6 +34,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 73e987dfa2..c3399b4739 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -53,6 +53,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index cd48c29075..cf24996c06 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts new file mode 100644 index 0000000000..380c0e97f2 --- /dev/null +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -0,0 +1,283 @@ +// Web e2e scenario: generic file upload round trip. A real chromium picks a +// file through the composer paperclip input; the upload RPC stores the exact +// bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged +// reference, request assembly projects the file block to handle text, and the +// model (replayed or live) reads the saved copy with the REAL read tool. The +// content-addressed store makes the saved path identical across record and +// replay once the workspace cwd is tokenized, so the recorded read arguments +// replay verbatim against a freshly re-uploaded object. +// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless +// DSH_SNAPSHOT=refresh regenerates ui.expected.md. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) +const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) +const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) +const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url)) +const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url)) +const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url)) +const MODE = webSnapshotMode() + +/** The uploaded fixture file: constant bytes so record and replay share one content digest. */ +const FILE_NAME = 'poem.txt' +const FILE_TEXT = 'UPLOAD_ROUND_OK\n' +const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.' +const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`) + +/** Browser-measured relations for the mixed composer attachment rail. */ +interface DraftRailGeometry { + readonly order: readonly string[] + readonly oneGroup: boolean + readonly oneRow: boolean + readonly equalHeight: boolean + readonly fileWider: boolean + readonly horizontalOverflow: boolean + readonly noWrap: boolean +} + +/** Render stable relations instead of platform-dependent absolute coordinates. */ +function renderDraftRailGeometry(geometry: DraftRailGeometry): string { + return [ + '# Mixed composer attachment rail', + '', + `- selection order: ${geometry.order.join(' > ')}`, + `- one attachment group: ${String(geometry.oneGroup)}`, + `- all cards share one row: ${String(geometry.oneRow)}`, + `- every card is 64px high: ${String(geometry.equalHeight)}`, + `- the file card is wider than an image: ${String(geometry.fileWider)}`, + `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`, + `- the rail does not wrap: ${String(geometry.noWrap)}`, + ].join('\n') +} + +/** Browser-measured relations for one durable mixed-attachment message. */ +interface HistoryAttachmentGeometry { + readonly order: readonly string[] + readonly oneGroup: boolean + readonly oneRow: boolean + readonly equalHeight: boolean + readonly imageIsTile: boolean + readonly fileWider: boolean + readonly wrapsWhenNeeded: boolean + readonly rightAligned: boolean +} + +/** Render stable history-layout relations instead of absolute coordinates. */ +function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string { + return [ + '# Mixed history attachment flow', + '', + `- source order: ${geometry.order.join(' > ')}`, + `- one attachment group: ${String(geometry.oneGroup)}`, + `- file and image share one row: ${String(geometry.oneRow)}`, + `- both cards are 64px high: ${String(geometry.equalHeight)}`, + `- the image is a 64px tile: ${String(geometry.imageIsTile)}`, + `- the file card is wider than the image: ${String(geometry.fileWider)}`, + `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`, + `- the group is right-aligned: ${String(geometry.rightAligned)}`, + ].join('\n') +} + +describe('web e2e: generic file upload through the real assembly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + compareReplaySession: true, + // The override rescripts the recorded read arguments with a + // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run, + // and the live handle line in the request carries the current one. + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.setViewportSize({ width: 900, height: 900 }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('[data-composer-input]').first() + await input.waitFor({ timeout: 10_000 }) + const modelTrigger = page.getByRole('button', { name: /^Select model, current/ }) + await modelTrigger.click() + await page.getByRole('menuitem', { name: /^Model\b/ }).click() + await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click() + await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toContain('DeepSeek-V4-Flash-Vision-Exp') + const imageBytes = await readFile(IMAGE_FIXTURE) + // Pick through the composer's hidden file input: the upload RPC runs + // immediately and the pending card appears before any prompt is typed. + await page.locator('input[type="file"]').setInputFiles([ + { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) }, + ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })), + ]) + await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 }) + const rail = page.getByRole('group', { name: 'Pending attachments' }) + await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }) + .toBe(IMAGE_NAMES.length + 1) + const geometry = await rail.evaluate((element): DraftRailGeometry => { + const cards = [...element.children] as HTMLElement[] + const boxes = cards.map(card => card.getBoundingClientRect()) + const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0 + const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0 + return { + order: cards.map(card => card.querySelector('img')?.getAttribute('alt') + ?? card.querySelector('[title]')?.title ?? ''), + oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1, + oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), + equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5), + fileWider: fileWidth > imageWidth, + horizontalOverflow: element.scrollWidth > element.clientWidth, + noWrap: getComputedStyle(element).flexWrap === 'nowrap', + } + }) + await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE) + for (const name of IMAGE_NAMES.slice(1)) { + await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true }) + } + await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2) + await input.fill(PROMPT) + // Send unlocks only after the upload receipt lands (the staged file gate). + const send = page.getByRole('button', { name: 'Send message' }) + await send.waitFor({ state: 'visible', timeout: 15_000 }) + await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true) + const settled = scaffold.whenTurnSettled() + await send.click() + const restoreDeadline = Date.now() + 10_000 + let retainedCards = 0 + while (retainedCards === 0 + && !sessionEvents.some(event => event.type === 'turn/start') + && Date.now() < restoreDeadline) { + await page.waitForTimeout(100) + retainedCards = await rail.locator(':scope > *').count() + } + if (retainedCards !== 0) { + const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents() + throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`) + } + const sessionId = await settled + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + }, 200_000) + + it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => { + const userMessage = sessionEvents.find( + (event): event is Extract => + event.type === 'user/message' && event.data.source.kind === 'user', + ) + if (userMessage === undefined) throw new Error('the replayed turn recorded no user message') + const fileBlock = userMessage.data.content.find(block => block.type === 'file') + if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block') + expect(fileBlock.attachment.name).toBe(FILE_NAME) + expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT)) + expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/) + + const readCall = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/call' && event.data.name === 'read', + ) + if (readCall === undefined) throw new Error('the replayed turn did not call the read tool') + expect(readCall.data.arguments).toContain(FILE_NAME) + const readResult = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId, + ) + if (readResult === undefined) throw new Error('the read call produced no durable result') + const content = readResult.data.message.content[0] + expect(content.isError).toBe(false) + expect(content.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toContain('UPLOAD_ROUND_OK') + + const turnEnds = sessionEvents.filter(event => event.type === 'turn/end') + expect(turnEnds.length).toBe(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + }) + + it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria')) + await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => { + const groups = page.locator('[data-message-attachments]') + await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1) + const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => { + const cards = [...element.children] as HTMLElement[] + const renderedCards = cards.map((card) => { + const box = card.getBoundingClientRect() + return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement + ? card.firstElementChild + : card + }) + const boxes = renderedCards.map(card => card.getBoundingClientRect()) + const imageIndex = cards.findIndex(card => card.querySelector('img') !== null) + const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt') + const imageBox = boxes[imageIndex] + const fileBox = boxes[fileIndex] + return { + order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''), + oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1, + oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), + equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5), + imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5, + fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width, + wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap', + rightAligned: getComputedStyle(element).justifyContent === 'flex-end', + } + }) + await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE) + }) + + it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[data-trajectory-row-key][aria-label*="Files ×1"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 3b5f8d00f2..886421262f 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -106,7 +106,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // The rail is an accessible group holding the draft thumbnail (queried via // DOM: jsdom's a11y-visibility computation hides the composer subtree). const rail = await waitFor(() => { - const el = document.querySelector('[role="group"][aria-label="Pending images"]') + const el = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) @@ -137,23 +137,19 @@ it('accepts pasted images into the composer rail in order and removes them', asy if (remove.length !== 2) throw new Error('remove buttons missing') for (const button of remove) fireEvent.click(button) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }) - // An unsupported file announces a transient toast (the inline strip is - // gone) and the banner dismisses itself after its hold-and-fade lifetime. + // A non-image paste follows the generic-file path and remains in the + // composer as a file card. fireEvent.paste(textarea, { clipboardData: { items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }], getData: () => '', }, }) - const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported' - const toast = await screen.findByText(unsupportedMessage) - expect(toast.closest('[role="alert"]')).not.toBeNull() - await waitFor(() => { - expect(screen.queryByText(unsupportedMessage)).toBeNull() - }, { timeout: 6_000 }) + const files = await screen.findByRole('group', { name: 'Pending attachments' }) + expect(files.textContent).toContain('notes.txt') }) it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => { @@ -178,15 +174,15 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') - expect(overlay.textContent).toContain('Drag images here to add them') + expect(overlay.textContent).toContain('Drag files or images here to add them') await waitFor(() => { - expect(overlay.textContent).toContain('Up to 20 images, 5MB each') + expect(overlay.textContent).toContain('Image limit: up to 20 images, 5MB each') }) // Dropping on the transcript area (not the composer card) lands in the rail. fireEvent.drop(document.body, { dataTransfer }) await waitFor(() => { - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (rail === null) throw new Error('attachment rail missing after page drop') expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png']) }, { timeout: 5_000 }) @@ -206,7 +202,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const limitMessage = 'A message can include up to 20 images' const banner = await screen.findByText(limitMessage) expect(banner.closest('[role="alert"]')).not.toBeNull() - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1) }) @@ -233,7 +229,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( }, }) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull() }) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -245,5 +241,5 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( "text": "Image sides must be at most 2000px; downscale it and try again", } `) - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull() }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 5a60a049ca..d06d23ab67 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -148,7 +148,18 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const REPLAY_PROVIDERS = [{ id: 'deepseek-official', name: 'DeepSeek', - models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }], + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }, + { + id: 'deepseek-v4-flash-vision-exp', + name: 'DeepSeek-V4-Flash-Vision-Exp', + contextWindow: 1_000_000, + inputModalities: ['text', 'image'] as const, + defaultMaxTokens: 256_000, + reasoningEfforts: ['off', 'low', 'high', 'max'], + defaultReasoningEffort: 'high', + }, + ], }] /** @@ -768,6 +779,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { let expected = await readFile(fixturePath, 'utf8') const userPrompts = fixtureUserPrompts(expected) @@ -861,7 +877,7 @@ async function assertReplaySession( if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`) const actual = rawSessionLog(session) if (mode === 'refresh') { - expected = stableSessionFixture(session, expected, sessionCwd) + expected = stableSessionFixture(session, expected, harnessHome) await writeFile(fixturePath, expected) } const expectedHeader = JSON.parse(expected.split('\n').find(line => line.trim() !== '') ?? '{}') as { @@ -873,8 +889,11 @@ async function assertReplaySession( sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [], cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0', } - expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`) - .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]) + const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0] + ?.split(harnessHome).join('{{harnessHome}}') + const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0] + ?.split(harnessHome).join('{{harnessHome}}') + expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot) const fixtureDir = dirname(fixturePath) const manifestPath = join(fixtureDir, 'snapshot.yml') @@ -898,8 +917,8 @@ async function assertReplaySession( /** * Record-mode fixture write-back: harvest the live session, scrub request - * headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque - * identities with typed relationship-preserving tokens, and write the fixture. + * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, + * redact opaque identities with typed relationship-preserving tokens, and write the fixture. * @param scaffold - the record-mode scaffold. * @param sessionId - the driven session. * @param fixturePath - the committed session.jsonl target. @@ -908,7 +927,7 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : '' - await writeFile(fixturePath, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd)) + await writeFile(fixturePath, stableSessionFixture(agent.session, existing, scaffold.harnessHome)) } /** @@ -955,8 +974,8 @@ export function fixtureIdentity( */ /** * Realize a recorded seed fixture against one scaffold: substitute the - * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the - * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to + * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the + * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to * price content exactly as the host will fold it) and still pass the result * through {@link seedSession}. * @param scaffold - the booted scaffold whose workspace the seed targets. @@ -971,6 +990,7 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`) .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) => fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal))) + .split('{{harnessHome}}').join(scaffold.harnessHome) .split('{{cwd}}').join(scaffold.workspaceCwd) const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd return fixtureCwd === undefined diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index cd696c5411..fec0614de5 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -27,6 +27,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current streaming-fence-highlight-test/streaming-fence": - text: streaming-fence-highlight-test/streaming-fence diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index a5d2b5503f..7693f3dc87 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -33,7 +33,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl }, }) await waitFor(() => { - if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + if (document.querySelector('[role="group"][aria-label="Pending attachments"] img') === null) { throw new Error('attachment rail missing') } }, { timeout: 5_000 }) @@ -53,7 +53,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') expect(composer.textContent).toBe('') expect(composer.getAttribute('contenteditable')).toBe('true') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() // The fixture's durable user/message (source.rpcId echoes the prompt // requestId) replaces the echo: one bubble, no marker left, and the image diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 23760125d3..6a08b8bf21 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", + "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..49bc2c9245 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -65,6 +65,7 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } + ctx.reflect.provide('connection', connection) ctx.reflect.provide('remote', { ...remote, $stream: (options: RemoteStreamOptions) => ( diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index bb9cd172ce..be2e8443a1 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -18,8 +18,9 @@ function entry(seq: number): SessionLiveEventEntry { } describe('Client Session contracts', () => { - it('keeps its catalog-visible prompt parts identical to attachment intake', () => { - expectTypeOf().toEqualTypeOf() + it('keeps its text and image prompt parts identical to attachment intake', () => { + expectTypeOf>() + .toEqualTypeOf() }) it('publishes exact replace, prepend, and append event-window changes', () => { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 97bd98e093..44d404bf38 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -112,6 +112,23 @@ describe('Session queue commands', () => { })).toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(steered) + const queuedFile = createUserMessage({ + content: [{ + type: 'file', + attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 }, + }], + source: { kind: 'user', rpcId: 'file-rpc' as never }, + }) + inbox.append('next-turn', queuedFile) + expect(controller.updateQueue({ + sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' }, + })).toEqual({ accepted: true }) + expect(steer).toHaveBeenLastCalledWith(queuedFile) + expect(queuedFile).toMatchObject({ + source: { kind: 'user', rpcId: 'file-rpc' }, + content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }], + }) + await expectFailure(Promise.resolve().then(() => controller.cancel({ sessionId: SessionId('missing'), })), 'session/not-found') diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts new file mode 100644 index 0000000000..73fa603aed --- /dev/null +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -0,0 +1,376 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, +} from '@deepseek-ai/dsh-attachment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import CommandRuntime from '@deepseek-ai/dsh-commands' +import { describe, expect, it, vi } from 'vitest' +import type { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { FileUploadReceiptId, SessionRequestId } from '../src/types.ts' + +const SESSION = SessionId('upload-session') + +async function uploadHarness(): Promise<{ + ctx: Context + controller: SessionCommandController + agent: Agent + followup: ReturnType + saveFile: ReturnType + saveFileStream: ReturnType + saveImages: ReturnType + disposeAgent: () => void +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandRuntime) + const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + const agent = { + id: session.id, + session, + inbox, + status: 'idle', + ctx, + steer: vi.fn(), + followup, + cancel: vi.fn(), + } as unknown as Agent + const disposeAgent = ctx.agents.register(agent) + const saveFile = vi.fn((input: SaveFileAttachment): Promise => Promise.resolve({ + attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), + name: input.name ?? 'file', + bytes: input.data.byteLength, + })) + const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise => { + let bytes = 0 + for await (const chunk of input.data) bytes += chunk.byteLength + return { + attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`), + name: input.name ?? 'file', + bytes, + } + }) + const saveImages = vi.fn((): Promise => + Promise.reject(new Error('fixture did not expect image persistence'))) + ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never) + ctx.provide('llm', { + listProviders: () => [{ id: 'fixture', name: 'Fixture' }], + resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), + } as never) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + } as unknown as ApiSessionAgentController + return { + ctx, + controller: new SessionCommandController(ctx, agents, '/workspace'), + agent, + followup, + saveFile, + saveFileStream, + saveImages, + disposeAgent, + } +} + +function promptRequest(content: Parameters[0]['content']) { + return { + requestId: 'req-1' as SessionRequestId, + sessionId: SESSION, + mode: 'queue' as const, + content, + } +} + +describe('Session file uploads', () => { + it('stages one verbatim upload and cites it from a later prompt as a file block', async () => { + const { ctx, controller, agent, followup, saveFile } = await uploadHarness() + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'notes.pdf' }) + expect(saveFile).toHaveBeenCalledTimes(1) + expect(receipt.file.name).toBe('notes.pdf') + expect(receipt.file.bytes).toBe(3) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) + expect(controller.resolveStagedFile(SessionId('other-session'), receipt.receiptId)).toBeUndefined() + expect(controller.resolveStagedFile(SESSION, 'missing' as FileUploadReceiptId)).toBeUndefined() + const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'files', description: 'Use staged files', input: { hint: '', attachments: true }, + handler: commandHandler, + }) + await ctx.commands.execute( + agent, + '/files inspect', + [{ type: 'file', receiptId: receipt.receiptId }], + new AbortController().signal, + ) + expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({ + attachments: [{ type: 'file', attachment: receipt.file }], + }) + await controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'text', text: 'read it' }, + ])) + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'file', attachment: receipt.file }, + { type: 'text', text: 'read it' }, + ]) + }) + + it('stages a bounded byte stream and forwards cancellation to storage', async () => { + const { controller, followup, saveFileStream } = await uploadHarness() + const abort = new AbortController() + const receipt = await controller.uploadFileStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4, 5) + })(), + signal: abort.signal, + name: 'huge.bin', + }) + expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({ + signal: abort.signal, + name: 'huge.bin', + })) + expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { type: 'file', attachment: receipt.file }, + ]) + }) + + it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => { + const { controller, saveFileStream } = await uploadHarness() + const stream = (async function* (): AsyncIterable {})() + await expect(controller.uploadFileStream({ sessionId: SESSION, data: stream })) + .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } }) + expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream }) + saveFileStream.mockRejectedValueOnce('disk offline') + await expect(controller.uploadFileStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + })) + .rejects.toMatchObject({ + code: 'gateway/internal', message: 'failed to store file upload: disk offline', + }) + }) + + it('unregisters its command receipt resolver with the owning plugin fiber', async () => { + const ctx = new Context() + const commands = ctx.plugin(CommandRuntime) + await commands.await() + const agents = {} as ApiSessionAgentController + const plugin = { + apply(ownerCtx: Context) { + new SessionCommandController(ownerCtx, agents, '/workspace') + }, + } + + const first = ctx.plugin(plugin) + await first.await() + await first.dispose() + const replacement = ctx.plugin(plugin) + await replacement.await() + await replacement.dispose() + await commands.dispose() + }) + + it('rejects a prompt citing a file that was never staged for the session', async () => { + const { controller, followup, saveImages } = await uploadHarness() + await expect(controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, + ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + expect(followup).not.toHaveBeenCalled() + expect(saveImages).not.toHaveBeenCalled() + }) + + it('publishes no receipt when its exact Agent is disposed during storage', async () => { + const { controller, saveFile, disposeAgent } = await uploadHarness() + const saved = Promise.withResolvers() + saveFile.mockReturnValueOnce(saved.promise) + const uploading = controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'late.bin' }) + await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() }) + disposeAgent() + saved.resolve({ + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3, + }) + await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('retires accepted receipts after their rpcId becomes observable', async () => { + const { controller } = await uploadHarness() + controller.retireObservedPrompt(SESSION, 'not-staged' as SessionRequestId) + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) + controller.retireObservedPrompt(SESSION, 'other-request' as SessionRequestId) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) + controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined() + }) + + it('deduplicates a retried rpcId already present in the Agent inbox', async () => { + const { controller, agent, followup } = await uploadHarness() + const request = promptRequest([{ type: 'text', text: 'once' }]) + await controller.prompt(request) + agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) + await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledOnce() + }) + + it('deduplicates a retried rpcId already present in the durable log', async () => { + const { controller, agent, followup } = await uploadHarness() + const request = promptRequest([{ type: 'text', text: 'once' }]) + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unidentified' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'accepted' }], + source: { kind: 'user', rpcId: request.requestId }, + }), { surfaceOp: 'append' }) + + await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + expect(followup).not.toHaveBeenCalled() + }) + + it('rejects when the Agent disappears during prompt admission', async () => { + const { controller, saveImages, disposeAgent } = await uploadHarness() + const admitted = Promise.withResolvers() + saveImages.mockReturnValueOnce(admitted.promise) + const prompting = controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ])) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + disposeAgent() + admitted.resolve([{ + attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + }]) + await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects when a previously bound receipt retires during image admission', async () => { + const { controller, saveImages } = await uploadHarness() + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + const admitted = Promise.withResolvers() + saveImages.mockReturnValueOnce(admitted.promise) + const prompting = controller.prompt({ + ...promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ]), + requestId: 'req-2' as SessionRequestId, + }) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) + admitted.resolve([{ + attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + }]) + await expect(prompting).rejects.toMatchObject({ + code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' }, + }) + }) + + it('keeps the prior receipt binding when a later prompt attempt fails', async () => { + const { controller, followup } = await uploadHarness() + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + followup.mockImplementationOnce(() => { throw new Error('busy') }) + await expect(controller.prompt({ + ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), + requestId: 'req-2' as SessionRequestId, + })).rejects.toMatchObject({ code: 'session/agent-busy' }) + controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined() + }) + + it('restores an unbound receipt after prompt delivery fails', async () => { + const { controller, followup } = await uploadHarness() + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + followup.mockImplementationOnce(() => { throw new Error('busy') }) + await expect(controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) + controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) + }) + + it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { + const { controller, agent, followup } = await uploadHarness() + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + const queued = followup.mock.calls[0]?.[0] as UserMessage + agent.inbox.append('next-turn', queued) + expect(controller.updateQueue({ + sessionId: SESSION, + itemId: queued.id, + action: { kind: 'remove' }, + })).toEqual({ accepted: true }) + expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined() + }) + + it('drops staged uploads with the session while the stored object stays durable', async () => { + const { controller, followup } = await uploadHarness() + const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + expect(receipt.file.name).toBe('file') + controller.releaseStagedFiles(SESSION) + await expect(controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + expect(followup).not.toHaveBeenCalled() + }) + + it('keeps separate names for identical bytes uploaded more than once', async () => { + const { controller, followup } = await uploadHarness() + const first = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'first.txt' }) + const second = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'second.txt' }) + expect(first.file.attachmentId).toBe(second.file.attachmentId) + expect(first.receiptId).not.toBe(second.receiptId) + + await controller.prompt(promptRequest([ + { type: 'file', receiptId: first.receiptId }, + { type: 'file', receiptId: second.receiptId }, + ])) + + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'file', attachment: first.file }, + { type: 'file', attachment: second.file }, + ]) + }) + + it('maps a non-canonical payload to the wire attachment error', async () => { + const { controller, saveFile } = await uploadHarness() + await expect(controller.uploadFile({ sessionId: SESSION, data: 'not base64!!' })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } }) + expect(saveFile).not.toHaveBeenCalled() + }) + + it('maps an unexpected storage failure to the internal wire error', async () => { + const { controller, saveFile } = await uploadHarness() + saveFile.mockRejectedValueOnce(new Error('disk unavailable')) + await expect(controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })) + .rejects.toMatchObject({ + code: 'gateway/internal', + message: 'failed to store file upload: Error: disk unavailable', + }) + }) +}) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index f31c2cb3c7..c6f3954c48 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -8,6 +8,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import SessionController from '../src/index.ts' import type { ApiSessionAgentController } from '../src/agent.ts' +import type { SessionRequestId } from '../src/types.ts' import { createSessionTestController, testSessionPersistence } from './test-remote.ts' const defaults = { @@ -38,6 +39,21 @@ describe('SessionController facade', () => { inspect, }) as never) const controller = createSessionTestController(ctx, defaults) + const uploadRequest = { sessionId, data: 'AAAA', name: 'notes.txt' } + const uploadValue = { + receiptId: 'receipt-1' as never, + file: { + attachmentId: `sha256:${'ab'.repeat(32)}` as never, + name: 'notes.txt', + bytes: 3, + }, + } + const uploadFile = vi.spyOn((controller as unknown as { + commands: { uploadFile(request: typeof uploadRequest): Promise } + }).commands, 'uploadFile').mockResolvedValue(uploadValue) + await expect(controller.uploadFile(uploadRequest, new AbortController().signal)) + .resolves.toBe(uploadValue) + expect(uploadFile).toHaveBeenCalledWith(uploadRequest) const status = vi.fn() const failure = vi.fn() const activity = vi.fn() @@ -60,6 +76,9 @@ describe('SessionController facade', () => { (controller as unknown as { agents: ApiSessionAgentController }).agents, 'consumeSelection', ) + const retireObservedPrompt = vi.spyOn((controller as unknown as { + commands: { retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void } + }).commands, 'retireObservedPrompt') await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) @@ -70,9 +89,14 @@ describe('SessionController facade', () => { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'controller-rpc' as never }, + }), { surfaceOp: 'append' }) expect(status).toHaveBeenCalledWith(sessionId, true) expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) + expect(retireObservedPrompt).toHaveBeenCalledWith(sessionId, 'controller-rpc') session.append('request/header', { header: { config: { provider: 'fixture', model: 'fixture-model' } }, reason: 'initial', @@ -95,7 +119,7 @@ describe('SessionController facade', () => { }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, - value: { type: 'snapshot', cursor: 1 }, + value: { type: 'snapshot', cursor: 2 }, }) abort.abort() await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..58c1beec7c 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -143,6 +143,13 @@ export class FakeApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) + onUploadFile: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + receiptId: 'r' as never, + file: { attachmentId: 'f' as never, name: 'file', bytes: 1 }, + })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onOpenWorkspacePath: (payload: unknown) => Promise> = @@ -225,6 +232,7 @@ export class FakeApiClient { fork: payload => this.record('session.fork', payload, this.onFork(payload)), prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), + uploadFile: payload => this.record('session.uploadFile', payload, this.onUploadFile(payload)), updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), openWorkspacePath: payload => this.record( diff --git a/packages/api/session-controller/tests/file-upload-http.host.spec.ts b/packages/api/session-controller/tests/file-upload-http.host.spec.ts new file mode 100644 index 0000000000..32ef3990c6 --- /dev/null +++ b/packages/api/session-controller/tests/file-upload-http.host.spec.ts @@ -0,0 +1,143 @@ +import { runInNewContext } from 'node:vm' +import { Context } from '@deepseek-ai/cordis' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import type { SessionCommandController } from '../src/commands.ts' +import { + handleSessionFileUploadHttp, registerSessionFileUploadHttp, +} from '../src/file-upload-http.ts' + +function request(input: { + method?: string + sessionId?: string + name?: string + contentType?: string + body?: Uint8Array +} = {}): Request { + const query = new URLSearchParams() + if (input.sessionId !== undefined) query.set('sessionId', input.sessionId) + if (input.name !== undefined) query.set('name', input.name) + const suffix = query.size === 0 ? '' : `?${query.toString()}` + return new Request(`http://host/api/session/uploadFileBinary${suffix}`, { + method: input.method ?? 'POST', + headers: input.contentType === undefined ? {} : { 'content-type': input.contentType }, + ...(input.body === undefined ? {} : { body: new Blob([Uint8Array.from(input.body).buffer]) }), + }) +} + +function commands(result: unknown): SessionCommandController & { + uploadFileStream: Mock + uploadedChunks: Uint8Array[] +} { + const uploadedChunks: Uint8Array[] = [] + const uploadFileStream = vi.fn(async (input) => { + for await (const chunk of input.data) uploadedChunks.push(chunk) + return await result as Awaited> + }) + return { + uploadedChunks, + uploadFileStream, + } as unknown as SessionCommandController & { + uploadFileStream: Mock + uploadedChunks: Uint8Array[] + } +} + +describe('background file upload Fetch route', () => { + it('registers one authenticated POST route on Connection', async () => { + const ctx = new Context() + const register = vi.fn((_route: unknown) => vi.fn(async () => {})) + ctx.provide('connection', { fetch: { register } } as never) + registerSessionFileUploadHttp(ctx, commands(Promise.resolve({}))) + await vi.waitFor(() => { expect(register).toHaveBeenCalledOnce() }) + const route = register.mock.calls[0]?.[0] as { + path: string + methods: string[] + requestBody: string + fetch(request: Request): Promise + } + expect(route.path).toBe('/api/session/uploadFileBinary') + expect(route.methods).toEqual(['POST']) + expect(route.requestBody).toBe('streaming') + expect((await route.fetch(request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).status).toBe(200) + await ctx.fiber.dispose() + }) + + it('rejects the wrong method, media type, and missing Session id without storing', async () => { + const controller = commands(Promise.resolve({})) + const wrongMethod = await handleSessionFileUploadHttp(controller, request({ method: 'GET' })) + expect(wrongMethod.status).toBe(405) + expect(wrongMethod.headers.get('allow')).toBe('POST') + + const wrongType = await handleSessionFileUploadHttp(controller, request({ contentType: 'application/json' })) + expect(wrongType.status).toBe(415) + expect(await wrongType.text()).toBe('content type must be application/octet-stream') + + const missingSession = await handleSessionFileUploadHttp( + controller, + request({ contentType: 'application/octet-stream' }), + ) + expect(missingSession.status).toBe(400) + expect(await missingSession.text()).toBe('sessionId is required') + expect(controller.uploadFileStream).not.toHaveBeenCalled() + }) + + it('stores the request bytes and returns the staged receipt', async () => { + const value = { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 }, + } + const controller = commands(Promise.resolve(value)) + const response = await handleSessionFileUploadHttp(controller, request({ + sessionId: 's1', + name: 'large & final.bin', + contentType: 'application/octet-stream; charset=binary', + body: Uint8Array.of(1, 2, 3, 4), + })) + expect(controller.uploadFileStream).toHaveBeenCalledOnce() + const upload = controller.uploadFileStream.mock.calls[0]?.[0] + expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' }) + expect(upload?.signal).toBeInstanceOf(AbortSignal) + expect(controller.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)]) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ ok: true, value }) + }) + + it('returns business and internal storage failures and keeps an absent name absent', async () => { + const business = commands(Promise.reject(new RemoteError( + 'session/attachment-invalid', 'denied', { reason: 'NOPE' }, + ))) + const businessResponse = await handleSessionFileUploadHttp(business, request({ + sessionId: 's1', contentType: 'application/octet-stream', + })) + expect(business.uploadFileStream).toHaveBeenCalledOnce() + const upload = business.uploadFileStream.mock.calls[0]?.[0] + expect(upload).toMatchObject({ sessionId: 's1' }) + expect(upload?.signal).toBeInstanceOf(AbortSignal) + expect(business.uploadedChunks).toEqual([]) + expect(await businessResponse.json()).toEqual({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }) + + const internal = commands(Promise.reject(new Error('disk offline'))) + expect(await (await handleSessionFileUploadHttp(internal, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).json()).toEqual({ + ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} }, + }) + + const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error + const exception = commands(Promise.reject(foreignError)) + expect(await (await handleSessionFileUploadHttp(exception, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).json()).toEqual({ + ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 856a861d5a..820a066a28 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -73,21 +73,29 @@ describe('Session queue snapshot intake', () => { ]) }) - it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => { + it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => { const session = makeSession() session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + content: [ + { type: 'text', text: 'hi' }, + { type: 'image', data: 'x' } as never, + { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never, + ], }])) const queue = session.getSnapshot().queue expect(typeof queue[0]?.messageId).toBe('string') expect(queue).toMatchObject([ { id: 'q-image', placement: 'queued', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], - // Image blocks render as thumbnails from `content`, so the preview - // carries only the text; non-image foreign blocks keep their marker. + content: [ + { type: 'text', text: 'hi' }, + { type: 'image', data: 'x' }, + { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } }, + ], + // Attachment blocks render from `content`, so the preview carries + // only text; other foreign blocks keep their marker. preview: 'hi', text: null, }, ]) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 20c374e8c3..94fc794514 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -48,6 +48,10 @@ function promptRequest( } } +function inboxFor(session: Session): Inbox { + return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) +} + function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra } } @@ -697,7 +701,9 @@ describe('subagent ownership fence', () => { meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 }, }) const followup = vi.fn() - const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + const agent = { + id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -716,7 +722,9 @@ describe('subagent ownership fence', () => { await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() - const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + const agent = { + id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -832,6 +840,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register({ id: session.id, session, + inbox: inboxFor(session), status: 'idle', ctx, followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index e5aad4564b..c425cff813 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' @@ -31,8 +31,20 @@ function imageRef(id: string): ImageAttachmentRef { } as unknown as ImageAttachmentRef } +function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { + return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef +} + +type AttachmentRef = ImageAttachmentRef | FileAttachmentRef + +function attachmentBlock(attachment: AttachmentRef) { + return 'mediaType' in attachment + ? { type: 'image' as const, attachment } + : { type: 'file' as const, attachment } +} + /** A durable browser-prompt user/message whose source echoes `rpcId`. */ -function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { +function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent { return { seq, time: 1_700_000_000_000 + seq, @@ -40,7 +52,7 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA surfaceOp: 'append', data: createUserMessage({ content: [ - ...refs.map(attachment => ({ type: 'image' as const, attachment })), + ...refs.map(attachmentBlock), { type: 'text' as const, text: '发送' }, ], source: { kind: 'user', rpcId }, @@ -48,14 +60,14 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA } as unknown as SessionEvent } -function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { +function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem { return { id: 'm-queued' as SessionQueuedItem['id'], placement: 'queued', rpcId, message: { id: 'm-queued' as SessionQueuedItem['id'], - content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'], }, } } @@ -72,23 +84,23 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '你好', - images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], }) expect(session.getSnapshot().promptAttempted).toBe(true) expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ requestId: handle.requestId, placement: 'transcript', text: '你好', - images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], }]) }) it('derives and captures the echo placement from running state and delivery mode', () => { const { session } = makeSession() - session.beginSubmission({ mode: 'queue', text: '空闲', images: [] }) + session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] }) session.handleRunning(true) - session.beginSubmission({ mode: 'queue', text: '排队', images: [] }) - session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] }) + session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] }) + session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] }) session.handleRunning(false) expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([ { text: '空闲', placement: 'transcript' }, @@ -103,7 +115,7 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '放弃', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) handle.abandon() @@ -121,7 +133,7 @@ describe('prompt-coupled retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '失败的', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) @@ -133,7 +145,7 @@ describe('prompt-coupled retirement', () => { it('sends the echo identity as the prompt requestId', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) }) @@ -141,7 +153,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) - session.beginSubmission({ mode: 'queue', text: '还在', images: [] }) + session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) }) @@ -156,7 +168,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '发送', - images: [{ previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', previewUrl: 'blob:p1' }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] @@ -176,7 +188,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '排队', - images: [{ previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', previewUrl: 'blob:p1' }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] @@ -188,9 +200,31 @@ describe('observed retirement', () => { expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) }) + it('retires a mixed echo with durable references in original selection order', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const file = fileRef('file-1') + const handle = session.beginSubmission({ + mode: 'queue', + text: 'mixed', + attachments: [ + { type: 'image', previewUrl: 'blob:first' }, + { type: 'file', attachment: file }, + { type: 'image', previewUrl: 'blob:last' }, + ], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('image-1'), file, imageRef('image-2')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never }) + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] }) api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) await session.open() await settleFrames() @@ -205,7 +239,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '先观察', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) @@ -222,7 +256,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '同一请求', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) session.handleControlFrame({ @@ -245,7 +279,7 @@ describe('observed retirement', () => { const { api, session } = makeSession() api.onHistory = () => Promise.resolve(ok(historyValue([]))) await session.open() - const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] }) await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) @@ -263,13 +297,13 @@ describe('disposal', () => { const observed = session.beginSubmission({ mode: 'queue', text: '已观察', - images: [], + attachments: [], onRetire: retirement => retirements.push({ text: '已观察', retirement }), }) session.beginSubmission({ mode: 'queue', text: '未settle', - images: [], + attachments: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), }) await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 899a3f7745..9665842c31 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -45,6 +45,127 @@ function histResponse(events: SessionEvent[], hasMore = false) { return Promise.resolve(ok(historyValue(events, hasMore))) } +describe('Session file upload', () => { + it('uses the background body carrier, reports progress, and validates its receipt', async () => { + const progress = vi.fn() + const post = vi.fn(async (request: { + path: string + body: Blob + headers?: Readonly> + signal?: AbortSignal + onProgress?: (progress: { loaded: number; total?: number }) => void + }) => { + request.onProgress?.({ loaded: 2, total: 4 }) + return { + status: 200, + body: JSON.stringify({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }), + } + }) + const { api, session } = makeSession(undefined, { backgroundUploads: { post } }) + const abort = new AbortController() + const file = new Blob([Uint8Array.of(1, 2, 3, 4)]) + + await expect(session.uploadFile(file, 'notes & refs.pdf', abort.signal, progress)).resolves.toEqual({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }) + expect(post).toHaveBeenCalledWith(expect.objectContaining({ + path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=notes+%26+refs.pdf', + body: file, + headers: { 'content-type': 'application/octet-stream' }, + signal: abort.signal, + })) + expect(progress).toHaveBeenCalledWith({ loaded: 2, total: 4 }) + expect(api.callsOf('session.uploadFile')).toEqual([]) + }) + + it('preserves a background business failure and supports unnamed files without observers', async () => { + const post = vi.fn((_request: { readonly body: Blob }) => Promise.resolve({ + status: 200, + body: JSON.stringify({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }), + })) + const { session } = makeSession(undefined, { backgroundUploads: { post } }) + await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }) + const request = post.mock.calls[0]?.[0] + expect(request).toMatchObject({ + path: '/api/session/uploadFileBinary?sessionId=fk-s1', + headers: { 'content-type': 'application/octet-stream' }, + }) + expect(request?.body).toBeInstanceOf(Blob) + }) + + it('keeps byte and Blob fallback uploads on the generated Remote carrier', async () => { + const { api, session } = makeSession() + await expect(session.uploadFile(Uint8Array.of(0, 0, 0), 'bytes.bin')).resolves.toMatchObject({ ok: true }) + await expect(session.uploadFile(new Blob([Uint8Array.of(1)]))).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('session.uploadFile')).toEqual([ + { sessionId: SID, data: 'AAAA', name: 'bytes.bin' }, + { sessionId: SID, data: 'AQ==' }, + ]) + }) + + it('folds non-200 and malformed background responses into transport failures', async () => { + const bodies: unknown[] = [ + null, + { ok: 'yes' }, + { ok: false, error: null }, + { ok: false, error: { code: 1, message: 'x', details: {} } }, + { ok: false, error: { code: 'x', message: 1, details: {} } }, + { ok: false, error: { code: 'x', message: 'x', details: null } }, + { ok: true, value: null }, + { ok: true, value: { receiptId: 1, file: {} } }, + { ok: true, value: { receiptId: 'r', file: null } }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 1, name: 'x', bytes: 1 } } }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 1, bytes: 1 } } }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: '1' } } }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: 1.5 } } }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, + ] + for (const body of bodies) { + const { session } = makeSession(undefined, { + backgroundUploads: { post: () => Promise.resolve({ status: 200, body: JSON.stringify(body) }) }, + }) + await expect(session.uploadFile(new Blob([]))) + .rejects.toThrow(/file upload transport returned an invalid/) + } + const { session } = makeSession(undefined, { + backgroundUploads: { post: () => Promise.resolve({ status: 503, body: 'unavailable' }) }, + }) + await expect(session.uploadFile(new Blob([]))) + .rejects.toThrow('file upload transport failed with HTTP 503') + }) + + it('refuses a direct file upload for a continuable subagent before either carrier runs', async () => { + const post = vi.fn() + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + backgroundUploads: { post }, + }) + await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({ + ok: false, + error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } }, + }) + expect(post).not.toHaveBeenCalled() + expect(api.callsOf('session.uploadFile')).toEqual([]) + }) +}) + describe('Session open', () => { it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { const { session } = makeSession() @@ -498,6 +619,29 @@ describe('prompt and cancel errors', () => { }) }) + it('rejects staged files instead of dropping them from subagent continuations', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + + const prompted = await session.prompt([ + { type: 'file', receiptId: 'receipt' as never }, + { type: 'text', text: '继续' }, + ], 'queue') + + expect(prompted).toMatchObject({ + ok: false, + error: { + code: 'subagent/attachment-invalid', + details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + }, + }) + expect(api.callsOf('subagents.prompt')).toEqual([]) + }) + it('sends a one-shot address to the Host under the continuable marker', async () => { const api = new FakeApiClient() api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( diff --git a/packages/client/connection/tests/background-upload.client.spec.ts b/packages/client/connection/tests/background-upload.client.spec.ts new file mode 100644 index 0000000000..04a73cf4b5 --- /dev/null +++ b/packages/client/connection/tests/background-upload.client.spec.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + backgroundUploadWorker, createBackgroundUploadTransport, +} from '../src/client/background-upload.ts' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('background upload worker body', () => { + it('sends the Blob with credentials and reports progress, completion, and failure', () => { + const posted: unknown[] = [] + const scope: { + onmessage: ((event: MessageEvent<{ + url: string + body: Blob + headers: Readonly> + }>) => void) | null + postMessage(message: unknown): void + } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } } + const xhr: { + upload: { onprogress: ((event: ProgressEvent) => void) | null } + status: number + responseText: string + withCredentials: boolean + onload: ((event: ProgressEvent) => void) | null + onerror: ((event: ProgressEvent) => void) | null + open: ReturnType + setRequestHeader: ReturnType + send: ReturnType + } = { + upload: { onprogress: null }, + status: 201, + responseText: '{"ok":true}', + withCredentials: false, + onload: null, + onerror: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + backgroundUploadWorker(scope, () => xhr as never) + const body = new Blob(['large']) + scope.onmessage?.({ + data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } }, + } as never) + expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload') + expect(xhr.withCredentials).toBe(true) + expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream') + expect(xhr.send).toHaveBeenCalledWith(body) + + xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent) + xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent) + xhr.onload?.({} as ProgressEvent) + xhr.onerror?.({} as ProgressEvent) + expect(posted).toEqual([ + { kind: 'progress', loaded: 2, total: 4 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 201, body: '{"ok":true}' }, + { kind: 'error', message: 'background upload transport failed' }, + ]) + }) + + it('uses the Worker globals when the emitted body supplies no test seams', () => { + const posted: unknown[] = [] + const scope: { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: unknown): void + } = { onmessage: null, postMessage: (message) => { posted.push(message) } } + const xhr = { + upload: { onprogress: null }, + status: 204, + responseText: '', + withCredentials: false, + onload: null, + onerror: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + vi.stubGlobal('self', scope) + vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr })) + backgroundUploadWorker() + scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent) + expect(xhr.send).toHaveBeenCalledOnce() + }) +}) + +describe('background upload page transport', () => { + it('hands Blob bodies to a custom Host Worker transport without reading them', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const body = new Blob(['opaque']) + const fetch = vi.fn((_url: URL, _init?: RequestInit) => + Promise.resolve(new Response('accepted', { status: 202 }))) + const signal = new AbortController().signal + await expect(createBackgroundUploadTransport(fetch).post({ + path: '/api/upload', body, headers: { 'x-test': 'yes' }, signal, + })).resolves.toEqual({ status: 202, body: 'accepted' }) + expect(fetch).toHaveBeenCalledWith(new URL('https://preview.test/api/upload'), { + method: 'POST', headers: { 'x-test': 'yes' }, body, signal, + }) + + vi.stubGlobal('location', { origin: 'null' }) + await createBackgroundUploadTransport(fetch).post({ path: '/fallback', body }) + expect(fetch.mock.calls[1]?.[0]).toEqual(new URL('http://dsh.internal/fallback')) + expect(fetch.mock.calls[1]?.[1]).toEqual({ method: 'POST', body }) + }) + + it('fails loud when a served browser has no Worker implementation', async () => { + vi.stubGlobal('Worker', undefined) + await expect(createBackgroundUploadTransport().post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload requires Web Worker support') + }) + + it('forwards progress and completion from a dedicated Worker and then terminates it', async () => { + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('location', { origin: 'https://harness.test' }) + const progress = vi.fn() + const body = new Blob(['bytes']) + const controller = new AbortController() + const pending = createBackgroundUploadTransport().post({ + path: '/api/upload', body, onProgress: progress, signal: controller.signal, + }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(created).toHaveBeenCalledOnce() + expect(revoked).toHaveBeenCalledWith('blob:worker') + expect(worker.url).toBe('blob:worker') + expect(worker.options).toEqual({ name: 'dsh-file-upload' }) + expect(worker.postMessage).toHaveBeenCalledWith({ + url: 'https://harness.test/api/upload', body, headers: {}, + }) + worker.onmessage?.({ data: { kind: 'progress', loaded: 2 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + controller.abort() + expect(progress).toHaveBeenCalledWith({ loaded: 2 }) + expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 }) + expect(worker.terminate).toHaveBeenCalledOnce() + }) + + it('rejects worker messages, worker errors, and caller cancellation', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static all: FakeWorker[] = [] + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.all.push(this) } + } + vi.stubGlobal('Worker', FakeWorker) + const transport = createBackgroundUploadTransport() + + const reported = transport.post({ path: '/upload', body: new Blob() }) + FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) + await expect(reported).rejects.toThrow('network failed') + + const errored = transport.post({ path: '/upload', body: new Blob() }) + FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) + await expect(errored).rejects.toThrow('worker crashed') + + const unnamed = transport.post({ path: '/upload', body: new Blob() }) + FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) + await expect(unnamed).rejects.toThrow('background upload worker failed') + + const controller = new AbortController() + const aborted = transport.post({ path: '/upload', body: new Blob(), signal: controller.signal }) + controller.abort() + await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() + + const already = new AbortController() + already.abort() + await expect(transport.post({ path: '/upload', body: new Blob(), signal: already.signal })) + .rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index d5dbee979e..ddb9b3b264 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -25,7 +25,8 @@ describe('Connection exact Fetch routes', () => { Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) const dispose = connection.fetch.register({ path: '/api/session.export', - methods: ['GET', 'HEAD'], + methods: ['GET', 'HEAD', 'POST'], + requestBody: 'streaming', fetch: route, }) const shared = connection.createSharedFetchHandler('/api') @@ -33,11 +34,18 @@ describe('Connection exact Fetch routes', () => { const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', )) + expect(shared.requestBodyMode({ + method: 'POST', url: new URL('http://host/api/session.export'), + })).toBe('streaming') + expect(shared.requestBodyMode({ + method: 'DELETE', url: new URL('http://host/api/session.export'), + })).toBe('buffered') expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) - expect(post.status).toBe(404) + expect(post.status).toBe(200) + expect(route).toHaveBeenCalledTimes(2) await dispose() const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) @@ -49,22 +57,26 @@ describe('Connection exact Fetch routes', () => { const { connection, dispose: disposeFiber } = await mounted() const fetch = async (): Promise => new Response() - expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], requestBody: 'buffered', fetch })) .toThrow('invalid exact Fetch route') - expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], requestBody: 'buffered', fetch })) .toThrow('declares no methods') expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'GET'], fetch, + requestBody: 'buffered', })).toThrow('repeats a method') const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET'], fetch, + requestBody: 'buffered', }) expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['HEAD'], fetch, + requestBody: 'buffered', })).toThrow('already registered') await dispose() expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['HEAD'], fetch, + requestBody: 'buffered', })).not.toThrow() await disposeFiber() }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index ed7eee91b3..103a9d9266 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -22,7 +22,7 @@ const sid = (id: string): SessionId => id as SessionId describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>( + const commands = await callRemote<{ name: string; input?: { hint: string; attachments?: boolean } }[]>( rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. @@ -30,7 +30,7 @@ describe('createFixtureApi commands/skills', () => { expect(echo?.input?.hint).toBeTruthy() expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() // Image acceptance is declared per descriptor; only goal and plan carry it. - expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan']) + expect(commands.filter(c => c.input?.attachments === true).map(c => c.name)).toEqual(['goal', 'plan']) }) it('rejects a catalog request for an unknown session', async () => { @@ -97,14 +97,14 @@ describe('createFixtureApi commands/skills', () => { const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] }) expect(refused?.commandId).toBeTruthy() - expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) + expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept attachments' }) await pump const events = frames .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, - { type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } }, + { type: 'command/done', data: { kind: 'error', text: '/echo does not accept attachments' } }, ]) }) @@ -129,13 +129,13 @@ describe('createFixtureApi commands/skills', () => { rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] }) expect(bareGoal?.result).toEqual({ kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', }) const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] }) expect(refused?.result).toEqual({ kind: 'error', - text: 'Image attachments cannot accompany /plan off.', + text: 'Attachments cannot accompany /plan off.', }) }) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 2e267ac966..c3d2eb6cd7 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -24,6 +24,7 @@ describe('HTTP bridge abort', () => { }) as unknown as ServerResponse await bridge(request, response, { + requestBodyMode: () => 'buffered', fetch: () => { throw new Error('a rejected request must never reach the handler') }, }, 1000) // The socket must not stay parked draining a body the client can trickle @@ -55,6 +56,7 @@ describe('HTTP bridge abort', () => { const started = new Promise((resolve) => { resolveStarted = resolve }) let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', fetch: async (input) => { const fetchRequest = input carrierSignal = fetchRequest.signal @@ -72,4 +74,74 @@ describe('HTTP bridge abort', () => { await pending expect(carrierSignal?.aborted).toBe(true) }) + + it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => { + const request = new Readable({ read() {} }) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session/uploadFileBinary?sessionId=s1', + method: 'POST', + headers: { + 'content-type': 'application/octet-stream', + 'content-length': String(Math.ceil(2.19 * 1024 ** 3)), + }, + }) + let status: number | undefined + const responseBytes: Uint8Array[] = [] + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number) { status = code; return this }, + write(chunk: Uint8Array) { responseBytes.push(chunk); return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + const received: Uint8Array[] = [] + const pending = bridge(request, response, { + requestBodyMode: () => 'streaming', + fetch: async (input) => { + resolveStarted() + if (input.body === null) throw new Error('streaming request lost its body') + for await (const chunk of input.body) received.push(chunk) + return new Response('stored') + }, + }, 1) + + await started + expect(received).toEqual([]) + request.push(Buffer.from([1, 2])) + request.push(Buffer.from([3, 4])) + request.push(null) + await pending + expect(status).toBe(200) + expect(received).toEqual([Uint8Array.of(1, 2), Uint8Array.of(3, 4)]) + expect(Buffer.concat(responseBytes).toString()).toBe('stored') + }) + + it('closes an unread streaming request after returning an early validation response', async () => { + const destroyed: true[] = [] + const request = new Readable({ read() {} }) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session/uploadFileBinary', + method: 'POST', + headers: { 'content-type': 'application/json' }, + destroy: () => { destroyed.push(true) }, + }) + let status: number | undefined + let headers: unknown + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number, values?: unknown) { status = code; headers = values; return this }, + write() { return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + await bridge(request, response, { + requestBodyMode: () => 'streaming', + fetch: () => Promise.resolve(new Response(null, { status: 415 })), + }, 1) + expect(status).toBe(415) + expect(headers).toMatchObject({ connection: 'close' }) + expect(destroyed).toEqual([true]) + }) }) diff --git a/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx index cc08a78d1c..089a16672a 100644 --- a/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx +++ b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx @@ -26,15 +26,16 @@ afterEach(() => { vi.unstubAllGlobals() }) const labels: AttachmentRailLabels = { group: '待发送图片', - open: '查看原图', scrollLeft: '向左滚动图片', scrollRight: '向右滚动图片', } function item(id: string): AttachmentRailItem { - return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` } + return { id } } +const renderItem = (entry: AttachmentRailItem) => {entry.id} + /** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) { Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true }) @@ -53,22 +54,16 @@ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollW } describe('AttachmentRail', () => { - it('renders thumbnails in order and routes open and remove clicks', () => { - const onOpen = vi.fn() - const onRemove = vi.fn() + it('renders owner-provided attachment cards in order', () => { const items = [item('a'), item('b')] - const view = render() + const view = render() const rail = view.getByRole('group', { name: '待发送图片' }) - expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png']) - fireEvent.click(view.getAllByTitle('查看原图')[0]!) - expect(onOpen).toHaveBeenCalledWith(items[0]) - fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' })) - expect(onRemove).toHaveBeenCalledWith(items[1]) + expect([...rail.children].map(child => child.textContent)).toEqual(['a', 'b']) }) it('shows edge arrows from scroll geometry and pages a viewport at a time', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -94,7 +89,7 @@ describe('AttachmentRail', () => { it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -110,7 +105,7 @@ describe('AttachmentRail', () => { it('keeps scrolling available when ResizeObserver is unavailable', () => { vi.stubGlobal('ResizeObserver', undefined) const view = render( - , + , ) expect(view.getByRole('group', { name: '待发送图片' })).toBeTruthy() view.unmount() @@ -118,7 +113,7 @@ describe('AttachmentRail', () => { it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -148,7 +143,7 @@ describe('AttachmentRail', () => { for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -162,16 +157,16 @@ describe('AttachmentRail', () => { it('reveals the rail end when an item is added, not when one is removed', () => { const first = [item('a'), item('b')] const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) view.rerender( - , + , ) expect(rail.scrollLeft).toBe(200) view.rerender( - , + , ) // Removal keeps the position; only growth jumps to the end. expect(rail.scrollLeft).toBe(200) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index ef77068f7e..8e6f01474e 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -22,24 +22,37 @@ afterEach(() => { const t = ((key: string, params?: Readonly>): string => { const messages: Record = { + 'attachment.pending': '待发送附件', + 'attachment.scrollLeft': '向左滚动附件', + 'attachment.scrollRight': '向右滚动附件', + 'file.pending': '待发送文件', + 'file.uploading': '上传中…', + 'file.uploadFailed': '上传失败,点击重试', + 'file.label': '文件', 'image.pending': '待发送图片', 'image.original': '原图', 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', 'image.openOriginal': '查看原图', - 'image.scrollLeft': '向左滚动图片', - 'image.scrollRight': '向右滚动图片', - 'image.dropBlocked': '当前无法添加图片', - 'image.dropTitle': '图片拖动到此处即可添加', + 'attachment.dropBlocked': '当前无法添加文件或图片', + 'attachment.dropTitle': '文件或图片拖动到此处即可添加', + } + if (key === 'file.remove') { + const name = params?.name + return `移除文件 ${typeof name === 'string' ? name : ''}` + } + if (key === 'file.retry') { + const name = params?.name + return `重试上传 ${typeof name === 'string' ? name : ''}` } if (key === 'image.remove') { const name = params?.name return `移除图片 ${typeof name === 'string' ? name : ''}` } - if (key === 'image.dropDesc') { + if (key === 'attachment.dropDesc') { const count = params?.count const size = params?.size - return `最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}` + return `图片限制:最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}` } return messages[key] ?? key }) as ComposerAttachmentsProps['t'] @@ -53,12 +66,22 @@ function attachment(id: string, name = `${id}.png`): ComposerAttachment { } } +function fileDraft(id: string, name = `${id}.pdf`): ComposerAttachment { + return { + kind: 'file', + id: id as ComposerAttachment['id'], + file: new File([Uint8Array.of(1, 2, 3)], name, { type: 'application/pdf' }), + } +} + function props(overrides: Partial = {}): ComposerAttachmentsProps { return { attachments: [], canAcceptDrop: true, - onAddImages: () => {}, - onRemoveImage: () => {}, + onAddFiles: () => {}, + onRemoveAttachment: () => {}, + uploads: {}, + onRetryFile: () => {}, t, ...overrides, } as unknown as ComposerAttachmentsProps @@ -66,9 +89,9 @@ function props(overrides: Partial = {}): Composer describe('ComposerAttachments', () => { it('accepts file drops anywhere on the document and keeps non-file drags native', () => { - const onAddImages = vi.fn() + const onAddFiles = vi.fn() const view = render() @@ -82,12 +105,12 @@ describe('ComposerAttachments', () => { const image = attachment('dropped').file const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) - expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加') - expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB') + expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加') + expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB') expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) - expect(onAddImages).toHaveBeenCalledWith([image]) + expect(onAddFiles).toHaveBeenCalledWith([image]) expect(view.queryByRole('status')).toBeNull() }) @@ -118,30 +141,30 @@ describe('ComposerAttachments', () => { }) it('shows a blocked drop without forwarding its files', () => { - const onAddImages = vi.fn() - const view = render() + const onAddFiles = vi.fn() + const view = render() const image = attachment('blocked').file const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } fireEvent.dragEnter(document.body, { dataTransfer }) - expect(view.getByRole('status').textContent).toBe('当前无法添加图片') + expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片') fireEvent.dragOver(document.body, { dataTransfer }) expect(dataTransfer.dropEffect).toBe('none') fireEvent.drop(document.body, { dataTransfer }) - expect(onAddImages).not.toHaveBeenCalled() + expect(onAddFiles).not.toHaveBeenCalled() expect(view.queryByRole('status')).toBeNull() }) it('routes rail removal and closes previews on Escape or attachment removal', () => { - const onRemoveImage = vi.fn() + const onRemoveAttachment = vi.fn() const image = attachment('draft-1', 'pixel.png') - const initial = props({ attachments: [image], onRemoveImage }) + const initial = props({ attachments: [image], onRemoveAttachment }) const view = render() fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) - expect(onRemoveImage).toHaveBeenCalledWith(image.id) + expect(onRemoveAttachment).toHaveBeenCalledWith(image.id) fireEvent.click(view.getByTitle('查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - view.rerender() + view.rerender() expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() view.rerender() @@ -150,6 +173,24 @@ describe('ComposerAttachments', () => { expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + it('keeps images and files in pick order inside one attachment rail', () => { + const view = render() + const rail = view.getByRole('group', { name: '待发送附件' }) + expect([...rail.children].map((child) => { + const image = child.querySelector('img') + return image?.getAttribute('alt') ?? child.querySelector('[title]')?.getAttribute('title') + })).toEqual(['first.png', 'middle.pdf', 'last.png']) + expect(view.queryByRole('group', { name: '待发送文件' })).toBeNull() + }) + it('labels an unnamed attachment and its original-image preview', () => { const image = attachment('unnamed', '') const view = render() @@ -158,3 +199,68 @@ describe('ComposerAttachments', () => { expect(view.getByAltText('原图')).toBeTruthy() }) }) + +describe('ComposerAttachments file drafts', () => { + it('renders uploading, ready, and failed cards with remove and retry affordances', () => { + const onRemoveAttachment = vi.fn() + const onRetryFile = vi.fn() + const view = render() + const group = view.getByRole('group', { name: '待发送附件' }) + expect(group.textContent).toContain('上传中…') + expect(view.container.querySelector('[style="width: 25%;"]')).toBeTruthy() + expect(group.textContent).toContain('ok.pdf') + expect(group.textContent).toContain('PDF 3B') + expect(group.textContent).toContain('上传失败,点击重试') + fireEvent.click(view.getByRole('button', { name: '重试上传 bad.pdf' })) + expect(onRetryFile).toHaveBeenCalledWith('bad') + fireEvent.click(view.getByRole('button', { name: '移除文件 ok.pdf' })) + expect(onRemoveAttachment).toHaveBeenCalledWith('ok') + }) + + it('treats a draft without upload state as uploading and keeps retry separate from remove', () => { + const onRetryFile = vi.fn() + const onRemoveAttachment = vi.fn() + const view = render() + expect(view.getByRole('group', { name: '待发送附件' }).textContent).toContain('上传中…') + const retry = view.getByRole('button', { name: '重试上传 bad.pdf' }) + const remove = view.getByRole('button', { name: '移除文件 bad.pdf' }) + expect(retry.contains(remove)).toBe(false) + fireEvent.click(remove) + expect(onRemoveAttachment).toHaveBeenCalledWith('bad') + expect(onRetryFile).not.toHaveBeenCalled() + fireEvent.click(retry) + expect(onRetryFile).toHaveBeenCalledWith('bad') + }) + + it('uses the localized file label when the browser supplies no name', () => { + const view = render() + const group = view.getByRole('group', { name: '待发送附件' }) + expect(group.textContent).toContain('文件') + expect(group.textContent).toContain('3B') + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 76a720b983..595f20cb1c 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -219,11 +219,16 @@ describe('ImageGallery', () => { expect(view.getByAltText('echo.png')).toBeTruthy() }) - it('renders a lone image large and several images as square tiles', () => { + it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() + const compact = render( + , + ) + expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) + compact.unmount() const several = render( , ) @@ -272,9 +277,9 @@ describe('ImageGallery', () => { useInput, inputActions: { setDraft: vi.fn(), - addImages: vi.fn(() => true), - removeImage: vi.fn(), - pruneImages: vi.fn(), + addAttachments: vi.fn(() => true), + removeAttachment: vi.fn(), + pruneAttachments: vi.fn(), submit: vi.fn(), }, images: [{ attachment }], diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7bcd9373b2..bfad1554a0 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -1041,3 +1041,29 @@ describe('small branch tails', () => { expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok') }) }) + +describe('user file attachments', () => { + it('renders one card per durable file block with its name and compact size', () => { + const view = render( + , + ) + expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') + expect(view.getByTitle('tiny.txt').textContent).toContain('12B') + expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + expect(view.getByText('summarize these')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9ebacea1de..c67414b4a9 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -355,9 +355,9 @@ function makeHarness( useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }, useStore: bindSnapshotSelector(chat), @@ -1012,7 +1012,7 @@ describe('ChatView', () => { pendingSubmissions: [ { requestId: 'req-1' as never, placement: 'transcript', - time: 5_000, text: '即发即显', images: [], + time: 5_000, text: '即发即显', attachments: [], }, ], }, @@ -1052,7 +1052,7 @@ describe('ChatView', () => { placement: 'steering', time: 5_500, text: '带图纠偏', - images: [{ previewUrl: 'blob:steer-preview', name: 'steer.png' }], + attachments: [{ type: 'image', previewUrl: 'blob:steer-preview', name: 'steer.png' }], }], }, ) @@ -1086,7 +1086,7 @@ describe('ChatView', () => { pendingSubmissions: [ { requestId: 'req-q' as never, placement: 'queued', - time: 6_000, text: '排队中', images: [], + time: 6_000, text: '排队中', attachments: [], }, ], }, @@ -1120,9 +1120,9 @@ describe('ChatView', () => { placement: 'transcript', time: 7_000, text: '', - images: [ - { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, - { previewUrl: 'blob:echo-b' }, + attachments: [ + { type: 'image', previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + { type: 'image', previewUrl: 'blob:echo-b' }, ], }], }, @@ -1130,17 +1130,74 @@ describe('ChatView', () => { const baseRenderSlot = h.props.renderSlot const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) - const images = (owner as { images: readonly unknown[] }).images - return
+ const { images, compact } = owner as { images: readonly unknown[]; compact?: boolean } + return ( +
+ ) }) as unknown as ChatViewSlotProps['renderSlot'] const view = render() - const gallery = view.getByTestId('echo-images') - expect(gallery.getAttribute('data-count')).toBe('2') - expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + const images = view.getAllByTestId('echo-image') + expect(images).toHaveLength(2) + expect(images.every(image => image.getAttribute('data-count') === '1')).toBe(true) + expect(images.every(image => image.getAttribute('data-compact') === 'true')).toBe(true) + expect(images[0]?.parentElement).toBe(images[1]?.parentElement) + expect(JSON.parse(images[0]?.getAttribute('data-first') ?? '{}')).toEqual({ preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, }) }) + it('a mixed echo renders the Web file card between its selected images', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-mixed' as never, + placement: 'transcript', + time: 7_500, + text: '', + attachments: [ + { type: 'image', previewUrl: 'blob:first', name: 'first.png' }, + { + type: 'file', + attachment: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 }, + }, + { type: 'image', previewUrl: 'blob:last', name: 'last.png' }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const { images, compact } = owner as { + images: ReadonlyArray<{ preview?: { name?: string } }> + compact?: boolean + } + return ( +
+ ) + }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const first = view.getByTestId('images-first.png') + const file = view.getByTitle('notes.txt') + const last = view.getByTestId('images-last.png') + expect(first.getAttribute('data-compact')).toBe('true') + expect(last.getAttribute('data-compact')).toBe('true') + expect(first.parentElement).toBe(file.parentElement) + expect(file.parentElement).toBe(last.parentElement) + expect(first.compareDocumentPosition(file) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(file.compareDocumentPosition(last) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(view.getByText('TXT 23B')).toBeTruthy() + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 57afd2abd8..9f7d3dd062 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -153,9 +153,9 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useStore={bindSnapshotSelector(chat)} @@ -215,9 +215,9 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useStore={bindSnapshotSelector(chat)} diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ae8ef7a884..673de3f9bd 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -13,7 +13,7 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -64,7 +64,7 @@ async function bench(opts: BenchOptions = {}) { const ctx = new Context() const registered = new Map() const listCalls: Array<{ sessionId: SessionId }> = [] - const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = [] + const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitAttachment[] }> = [] // The service reads the generated commands Remote, which delivers the // carrier's outcome, so a programmed failure answers the error branch. const commandsRemote = { @@ -77,7 +77,7 @@ async function bench(opts: BenchOptions = {}) { return value.commands }) }, - execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => { + execute: async (sessionId: SessionId, line: string, images: readonly SubmitAttachment[] = []) => { executeCalls.push({ sessionId, line, images }) return await carried(async () => { const fallback = (): Promise => Promise.resolve({ matched: true }) @@ -288,9 +288,9 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration()) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { attachments: 0 })).toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) - const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 }) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { attachments: 0 }) if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') expect(argued.claim.token).toBe('/goal ') }) @@ -309,7 +309,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration({ name: 'phantom' })) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined() + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { attachments: 0 })).toBeUndefined() expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) @@ -318,7 +318,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source, warm, executeCalls } = await bench() command.decorate(goalDecoration({ name: 'plan', available: () => false })) await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })).toBe('handled') expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) @@ -431,7 +431,7 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => new Promise((resolve) => { release = resolve }), }) - const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 }) + const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 }) release({ commands: S1_CMDS }) const outcome = await wait if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') @@ -442,14 +442,14 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => Promise.reject(new Error('warmup boom')), }) - await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom') + await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { attachments: 0 })).rejects.toThrow('warmup boom') }) it('leadingInput claims args-tolerant (bare and with trailing text)', async () => { const { source, warm } = await bench() await warm(proj('s1')) for (const line of ['/goal', '/goal refactor the loop']) { - const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 }) + const outcome = await source.matchEnter!(proj('s1'), line, signal(), { attachments: 0 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/goal ') } @@ -464,7 +464,7 @@ describe('matchEnter (enter column)', () => { return true }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 0 })).resolves.toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) await Promise.resolve() expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) @@ -473,7 +473,7 @@ describe('matchEnter (enter column)', () => { it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { attachments: 0 })).resolves.toBeUndefined() expect(executeCalls).toEqual([]) }) @@ -481,18 +481,18 @@ describe('matchEnter (enter column)', () => { const { command, source, mint, listCalls } = await bench() command.register(themeContribution()) const scope = mint('s1') - await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 0 })).resolves.toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true) expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady - await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { attachments: 0 })).resolves.toBeUndefined() }) it('unknown name, bare "/", and non-slash lines → undefined', async () => { const { source, warm } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { attachments: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal(), { attachments: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { attachments: 0 })).resolves.toBeUndefined() }) }) @@ -500,19 +500,19 @@ describe('matchEnter envelope policy (images)', () => { const signal = () => new AbortController().signal const IMG_CMDS: CommandDescriptor[] = [ ...S1_CMDS, - { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } }, + { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', attachments: true } }, ] - const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => { const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}') - const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 }) + await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"goal"}') + const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { attachments: 1 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/vision ') - expect(outcome.claim.images).toBe(true) + expect(outcome.claim.attachments).toBe(true) }) it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => { @@ -521,18 +521,18 @@ describe('matchEnter envelope policy (images)', () => { command.decorate({ name: 'plan', available: () => true, ui: themeUi() }) const scope = mint('s1') await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}') - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"theme"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 2 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) it('bare host detached execute refuses images before any RPC', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}') expect(executeCalls).toEqual([]) }) @@ -543,7 +543,7 @@ describe('matchEnter envelope policy (images)', () => { execute: () => Promise.resolve({ matched: true, result }), }) await warm(proj('s1')) - const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 }) + const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { attachments: 1 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') // Handler error: the error outcome keeps draft and images in the composer. await expect(outcome.claim.submit('x', new Context(), [png])) @@ -643,7 +643,7 @@ describe('detached admission notices', () => { // Admission miss (matched:false): immediate composer feedback stays. mode = 'miss' - await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 }) await flush() expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }]) @@ -722,7 +722,7 @@ describe('popupFor', () => { consumes.push(r) return true }) - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 }) const popup = command.popupFor(scope.ctx) await Promise.resolve() await popup.select(0) @@ -733,7 +733,7 @@ describe('popupFor', () => { const { command, source, mint } = await bench() command.register(themeContribution()) const scope = mint('s1') - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 }) const popup = command.popupFor(scope.ctx) expect(popup.state.getSnapshot().open).toBe(true) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index a767f54708..dbeb7348ba 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -28,6 +28,13 @@ function sessionFakeFor() { loadOlder: vi.fn(() => Promise.resolve()), prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + uploadFile: vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'root-receipt' as never, + file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })), } satisfies SessionBehaviorOverrides } @@ -213,6 +220,31 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('releases a draft attachment only after the input shell accepts its removal', async () => { + const b = await bench() + const composer = b.composerApi(ROOT) + expect(composer.addFiles?.([ + new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), + ])).toBeNull() + const controller = b.runtime.ctx.get('conversation') as unknown as { + releaseDraftAttachment(id: string): void + } + const release = vi.spyOn(controller, 'releaseDraftAttachment') + const input = b.inputApi(ROOT).actions + const remove = vi.spyOn(input, 'removeAttachment').mockReturnValue(false) + const draft = composer.resolveDraftAttachments?.( + b.inputApi(ROOT).state.getSnapshot().attachmentIds, + )[0] + if (draft === undefined) throw new Error('missing draft attachment') + + composer.removeAttachment?.(draft.id) + expect(release).not.toHaveBeenCalled() + remove.mockReturnValueOnce(true) + composer.removeAttachment?.(draft.id) + expect(release).toHaveBeenCalledWith(draft.id) + await b.runtime.dispose() + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') @@ -240,6 +272,10 @@ describe('Conversation inject API', () => { const resident = b.residentApi(ROOT) const { state, actions } = b.inputApi(ROOT) actions.setDraft('carry me') + expect(b.composerApi(ROOT).addFiles?.([ + new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), + ])).toBeNull() + await vi.waitFor(() => { expect(b.sessionFake.uploadFile).toHaveBeenCalledOnce() }) b.connectWorkspace.mockResolvedValueOnce(ROOT) await resident.selectWorkspace('workspace-1' as WorkspaceId) @@ -247,12 +283,21 @@ describe('Conversation inject API', () => { expect(state.getSnapshot().draft).toBe('carry me') const other = 'other-1' as SessionId - await b.runtime.sessions.add({ id: other }, { current: false }) + const targetUpload = vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'target-receipt' as never, + file: { attachmentId: 'target-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })) + await b.runtime.sessions.add({ id: other, session: { uploadFile: targetUpload } }, { current: false }) b.connectWorkspace.mockResolvedValueOnce(other) await resident.selectWorkspace('workspace-2' as WorkspaceId) expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] }) expect(state.getSnapshot().draft).toBe('') expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me') + await vi.waitFor(() => { expect(targetUpload).toHaveBeenCalledOnce() }) + expect(b.inputApi(other).state.getSnapshot().attachmentIds).toHaveLength(1) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index ba52e36ba6..c772d6dbfc 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -46,6 +46,7 @@ function fakeSession(): SessionFace { subscribe: listener => snapshot.subscribe(listener), beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }), prompt: () => Promise.reject(new Error('unused fake Session operation')), + uploadFile: () => Promise.reject(new Error('unused fake Session operation')), readAttachment: () => Promise.reject(new Error('unused fake Session operation')), updateQueue: () => Promise.reject(new Error('unused fake Session operation')), cancel: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 94aec70bbf..92ab0b5d69 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -51,6 +51,8 @@ interface BenchOptions { planEntry?: React.ReactNode /** The `plan` projection value the standard-kit useProjection serves. */ plan?: { active: boolean; pending: boolean } + /** The `goal` projection value used only to prove attachment intake remains ordinary. */ + goal?: { phase: 'active'; objective: string } modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> @@ -86,7 +88,7 @@ interface BenchOptions { leftItems?: React.ReactNode rightItems?: React.ReactNode attachments?: readonly ComposerAttachment[] - addImages?: (files: readonly File[]) => string | null + addFiles?: (files: readonly File[]) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -104,7 +106,7 @@ function row(id: string): SessionSnapshot['queue'][number] { function bench(over?: BenchOptions) { const sink = vi.fn<( text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: 'queue' | 'steer', signal: AbortSignal, ) => Promise>(() => Promise.resolve({ kind: 'success' })) @@ -120,7 +122,7 @@ function bench(over?: BenchOptions) { const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, - commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` }, + commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` }, queue: { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), @@ -138,9 +140,9 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id)) + if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id)) const stop = vi.fn() - const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) }) + const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) }) const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { @@ -165,13 +167,17 @@ function bench(over?: BenchOptions) { useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'permissions' ? over?.permissions - : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)), + : key === 'plan' ? over?.plan + : key === 'goal' ? over?.goal + : key === 'imageLimits' ? over?.imageLimits : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: over?.addImages ?? (() => null), - removeImage, - draftImages: ids => ids.flatMap((id) => { + addFiles: over?.addFiles ?? (() => null), + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment, + resolveDraftAttachments: ids => ids.flatMap((id) => { const attachment = over?.attachments?.find(candidate => candidate.id === id) return attachment === undefined ? [] : [attachment] }), @@ -210,7 +216,7 @@ function bench(over?: BenchOptions) { )! const interruptButton = view.container.querySelector('button[aria-label="停止生成"]') return { - view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls, + view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls, menuLauncher, steerQueue: over?.steerQueue, get placeholder() { return placeholderOf(view.container) }, @@ -243,8 +249,8 @@ function writeDraft(shell: SessionInputShell, text: string): void { describe('image draft rail', () => { it('collects clipboard files while preserving text from a mixed paste', async () => { - const addImages = vi.fn(() => null) - const { textarea, shell } = bench({ addImages }) + const addFiles = vi.fn(() => null) + const { textarea, shell } = bench({ addFiles }) const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -255,7 +261,7 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addImages).toHaveBeenCalledWith([image]) + expect(addFiles).toHaveBeenCalledWith([image]) // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) }) @@ -271,40 +277,40 @@ describe('image draft rail', () => { } const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' }) const intake = (result: ReturnType, files: File[]) => { - act(() => { attachmentOwner(result.slotCalls).onAddImages(files) }) + act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) } // Count: three at once over a two-image limit → the whole batch refused. - const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const overCount = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')]) expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片') - expect(overCount.props.addImages).not.toHaveBeenCalled() + expect(overCount.props.addFiles).not.toHaveBeenCalled() cleanup() // Per-file bytes. - const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const overFile = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) intake(overFile, [png(1024 * 1024 + 1, 'big.png')]) expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB') - expect(overFile.props.addImages).not.toHaveBeenCalled() + expect(overFile.props.addFiles).not.toHaveBeenCalled() cleanup() // Aggregate bytes across the existing rail plus the new batch. const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' }) const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' } - const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) + const overTotal = bench({ addFiles: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) intake(overTotal, [png(1024 * 1024, 'more.png')]) expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB') - expect(overTotal.props.addImages).not.toHaveBeenCalled() + expect(overTotal.props.addFiles).not.toHaveBeenCalled() cleanup() - // Within every limit: the batch passes through to addImages. - const within = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + // Within every limit: the batch passes through to addFiles. + const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) const fits = png(16, 'fits.png') intake(within, [fits]) - expect(within.props.addImages).toHaveBeenCalledWith([fits]) + expect(within.props.addFiles).toHaveBeenCalledWith([fits]) expect(within.view.queryByRole('alert')).toBeNull() }) it('announces the format problem before any limit when the batch holds a non-image', () => { - const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') const result = bench({ - addImages, + addFiles, imageLimits: { maxImageBytes: 8, maxImagesPerMessage: 1, @@ -319,14 +325,14 @@ describe('image draft rail', () => { new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }), new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), ] - act(() => { attachmentOwner(result.slotCalls).onAddImages(files) }) - expect(addImages).toHaveBeenCalledWith(files) + act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) + expect(addFiles).toHaveBeenCalledWith(files) expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') }) it('projects display-ready limits into the attachment slot', () => { const result = bench({ - addImages: vi.fn(() => null), + addFiles: vi.fn(() => null), imageLimits: { maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, @@ -368,7 +374,7 @@ describe('image draft rail', () => { }) it('marks the attachment slot unavailable while the composer is locked', () => { - const result = bench({ addImages: vi.fn(() => null), inert: true }) + const result = bench({ addFiles: vi.fn(() => null), inert: true }) expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false) }) @@ -380,11 +386,11 @@ describe('image draft rail', () => { { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' }, ] const result = bench({ attachments }) - const { view, textarea, sink, removeImage } = result + const { view, textarea, sink, removeAttachment } = result expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) const owner = attachmentOwner(result.slotCalls) - act(() => { owner.onRemoveImage('draft-2' as DraftAttachmentId) }) - expect(removeImage).toHaveBeenCalledWith('draft-2') + act(() => { owner.onRemoveAttachment('draft-2' as DraftAttachmentId) }) + expect(removeAttachment).toHaveBeenCalledWith('draft-2') let settle!: (outcome: SubmitOutcome) => void sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -416,8 +422,8 @@ describe('image draft rail', () => { it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { vi.useFakeTimers() try { - const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') - const { view, textarea } = bench({ addImages }) + const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const { view, textarea } = bench({ addFiles }) const paste = () => { fireEvent.paste(textarea, { clipboardData: { @@ -439,10 +445,10 @@ describe('image draft rail', () => { }) it('announces a rejected attachment-slot intake through the same toast', () => { - const addImages = vi.fn(() => '图片读取服务不可用') - const result = bench({ addImages }) + const addFiles = vi.fn(() => '图片读取服务不可用') + const result = bench({ addFiles }) act(() => { - attachmentOwner(result.slotCalls).onAddImages([ + attachmentOwner(result.slotCalls).onAddFiles([ new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }), ]) }) @@ -775,7 +781,7 @@ describe('running and lock semantics', () => { }) it('running continuable subagent keeps Send beside an independent Stop', () => { - const { button, interruptButton, textarea, sink, stop } = bench({ + const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({ running: true, draft: '后续消息', subagent: { @@ -790,12 +796,26 @@ describe('running and lock semantics', () => { expect(button.getAttribute('aria-label')).toBe('发送消息') expect(interruptButton).not.toBeNull() expect(textarea.getAttribute('aria-disabled')).not.toBe('true') + expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) + expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(true) + expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false) fireEvent.click(button) expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) + it.each([ + ['active plan', { plan: { active: true, pending: false } }], + ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }], + ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => { + const added = vi.fn(() => null) + const { view, slotCalls } = bench({ ...projection, addFiles: added }) + expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false) + expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(false) + expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true) + }) + it('parent-offline running continuable locks Send but keeps independent Stop usable', () => { const { button, interruptButton, textarea, stop, view } = bench({ running: true, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index bdb878672b..5224973369 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -15,7 +15,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SubmitImageAttachment, SubmitOutcome } from '../src/client/contract/input.ts' +import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' @@ -63,10 +63,12 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: () => null, - removeImage: () => {}, + addFiles: () => null, + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment: () => {}, // Every id resolves so the bar's registry prune never drops a test image. - draftImages: ids => ids.map(id => ({ + resolveDraftAttachments: ids => ids.map(id => ({ kind: 'image' as const, id, file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), previewUrl: `blob:${id}`, @@ -89,22 +91,22 @@ function bench(over?: { running?: boolean disabled?: boolean submit?: (args: string) => Promise - serialize?: (ids: readonly DraftAttachmentId[]) => Promise + serialize?: (ids: readonly DraftAttachmentId[]) => Promise }) { const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) - const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) + const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) const release = vi.fn() - const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) const wiring = shell const view = mountBar(shell, over) const textarea = view.container.querySelector('[data-composer-input]')! - const claim = (token = '/goal ', hint = '目标', images?: true) => { + const claim = (token = '/goal ', hint = '目标', attachments?: true) => { act(() => { shell.setDraft(token) shell.beginCommand( { token, hint, - ...(images === true ? { images: true } : {}), + ...(attachments === true ? { attachments: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, { start: 0, end: token.length, draftRev: shell.snapshot.draftRev }, @@ -168,50 +170,52 @@ describe('matrix row: claimed', () => { }) }) -describe('matrix row: claimed with images', () => { +describe('matrix row: claimed with attachments', () => { const img = 'img-1' as DraftAttachmentId - it('a claim without image acceptance blocks enter: one notice, draft/images/claim retained', async () => { + it('a claim without attachment acceptance blocks enter: one notice and the full draft retained', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const { view, textarea, shell, sink, claim } = bench({ submit }) claim() - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await Promise.resolve() expect(shell.snapshot.phase).toBe('claimed') expect(submit).not.toHaveBeenCalled() expect(sink).not.toHaveBeenCalled() - expect(view.getByText('/goal images-unsupported')).toBeTruthy() - expect(shell.snapshot.imageIds).toEqual([img]) + expect(view.getByText('/goal attachments-unsupported')).toBeTruthy() + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(shell.snapshot.draft).toBe('/goal ') }) - it('an accepting claim serializes and forwards the images; success consumes and clears', async () => { + it('an accepting claim serializes and forwards a mixed batch; success consumes and clears', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } - const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png]) }) + const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } + const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' } + const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) }) claim('/goal ', '目标', true) // The claim currency carries the acceptance flag the pre-gate reads. - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', images: true }) - act(() => { shell.addImages([img]) }) + expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) }) expect(serialize).toHaveBeenCalledWith([img]) await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') }) expect(release).toHaveBeenCalledWith([img]) - expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.attachmentIds).toEqual([]) expect(shell.snapshot.phase).toBe('plain') }) - it('a handler error outcome keeps the images unreleased beside the notice and the draft', async () => { + it('a handler error keeps a serialized file unreleased beside the notice and draft', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' })) - const { view, textarea, shell, claim, release } = bench({ submit }) + const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' } + const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.resolve([file]) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() }) expect(shell.snapshot.phase).toBe('claimed') - expect(shell.snapshot.imageIds).toEqual([img]) + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(release).not.toHaveBeenCalled() expect(shell.snapshot.draft).toBe('/goal ') }) @@ -220,28 +224,28 @@ describe('matrix row: claimed with images', () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() }) expect(submit).not.toHaveBeenCalled() - expect(shell.snapshot.imageIds).toEqual([img]) + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(release).not.toHaveBeenCalled() expect(shell.snapshot.phase).toBe('claimed') }) it('a disposed shell never lets a pending serialization reach claim.submit', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - let resolveSerialize!: (images: readonly SubmitImageAttachment[]) => void + let resolveSerialize!: (images: readonly SubmitAttachment[]) => void const { shell, textarea, claim } = bench({ submit, serialize: () => new Promise((resolve) => { resolveSerialize = resolve }), }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() }) shell.dispose() - resolveSerialize([{ mediaType: 'image/png', data: 'AA==' }]) + resolveSerialize([{ type: 'image', mediaType: 'image/png', data: 'AA==' }]) await Promise.resolve() await Promise.resolve() expect(submit).not.toHaveBeenCalled() @@ -251,11 +255,13 @@ describe('matrix row: claimed with images', () => { const submit = vi.fn(() => new Promise(() => {})) // never settles const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') - act(() => { shell.removeImage(img) }) - expect(shell.snapshot.imageIds).toEqual([img]) + let removed = true + act(() => { removed = shell.removeAttachment(img) }) + expect(removed).toBe(false) + expect(shell.snapshot.attachmentIds).toEqual([img]) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index c0319e9e36..57f10879de 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -11,10 +11,10 @@ import type { DraftAttachmentId } from '../src/client/contract/input.ts' const mention = '@[Research](dsh-session:InNvdXJjZSI)' const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)' -const commandImages = { +const commandAttachments = { serialize: () => Promise.resolve([]), release: () => {}, - unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`, + unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported`, } function chip(shell: SessionInputShell): void { @@ -38,7 +38,7 @@ describe('reference submission', () => { const first = new SessionInputShell({ actx: {} as Context, defaultSink: vi.fn(), - commandImages, + commandAttachments, }) first.bindMirror(mirror) first.setDraft('@res') @@ -62,7 +62,7 @@ describe('reference submission', () => { const restored = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string) restored.submit() @@ -90,7 +90,7 @@ describe('reference submission', () => { actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, - commandImages, + commandAttachments, }) chip(shell) expect(shell.snapshot).toMatchObject({ @@ -136,7 +136,7 @@ describe('reference submission', () => { actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, - commandImages, + commandAttachments, }) chip(shell) shell.submit() @@ -160,7 +160,7 @@ describe('reference submission', () => { signal = received return new Promise(() => {}) }, - commandImages, + commandAttachments, }) shell.setDraft('send this') shell.submit() @@ -177,7 +177,7 @@ describe('reference submission', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: () => Promise.resolve({ kind: 'error' }), - commandImages, + commandAttachments, }) shell.setDraft('retry this') shell.submit() @@ -193,7 +193,7 @@ describe('reference submission', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: () => new Promise((resolve) => { settlements.push(resolve) }), - commandImages, + commandAttachments, }) shell.setDraft('first') shell.submit() @@ -215,18 +215,18 @@ describe('submit transaction hardening', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) - expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true) shell.submit('queue') shell.submit('queue') expect(sink).toHaveBeenCalledTimes(1) settle({ kind: 'success' }) await vi.waitFor(() => { - expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.attachmentIds).toEqual([]) }) - expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true) shell.submit('queue') expect(sink).toHaveBeenCalledTimes(2) }) @@ -236,14 +236,14 @@ describe('submit transaction hardening', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) const imageId = 'img-1' as DraftAttachmentId - shell.addImages([imageId]) + shell.addAttachments([imageId]) shell.submit() await Promise.resolve() await Promise.resolve() - expect(shell.snapshot.imageIds).toEqual([imageId]) + expect(shell.snapshot.attachmentIds).toEqual([imageId]) expect(shell.notices.getSnapshot()).toBeNull() }) @@ -256,9 +256,9 @@ describe('submit transaction hardening', () => { signal = received return new Promise(() => {}) }, - commandImages, + commandAttachments, }) - shell.addImages([imageId]) + shell.addAttachments([imageId]) shell.submit() expect(signal?.aborted).toBe(false) expect(shell.dispose()).toEqual([imageId]) @@ -272,7 +272,7 @@ describe('submit transaction hardening', () => { actx: {} as Context, inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController), defaultSink: vi.fn(), - commandImages, + commandAttachments, }) shell.setDraft('@sr') const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index ec7f586683..108385664f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -17,7 +17,7 @@ import type { ClientSessionContext, SubmitEnvelope, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { - CommandClaim, PickOutcome, SubmitImageAttachment, SubmitOutcome, + CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome, } from '../src/client/contract/input.ts' import { bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, @@ -44,19 +44,19 @@ afterEach(cleanup) interface FakeCommand { name: string description: string - input?: { hint: string; images?: boolean } + input?: { hint: string; attachments?: boolean } } /** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */ function commandSource( commands: FakeCommand[], - execute: (line: string, images?: readonly SubmitImageAttachment[]) => Promise, + execute: (line: string, images?: readonly SubmitAttachment[]) => Promise, ) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), + ...(desc.input?.attachments === true ? { attachments: true } : {}), submit: (args, _actx, images) => execute(`/${desc.name} ${args}`, images), }) const executed: string[] = [] @@ -105,10 +105,10 @@ function commandSource( const COMMANDS: FakeCommand[] = [ { name: 'goal', description: '设定目标', input: { hint: '目标内容' } }, { name: 'compact', description: '压缩上下文' }, - { name: 'vision', description: '识别图片', input: { hint: '想问什么', images: true } }, + { name: 'vision', description: '识别图片', input: { hint: '想问什么', attachments: true } }, ] -const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } +const PNG: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } /** Real scope bench: a Controller-owned Session scope + InputTriggerController + shell listeners. */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { @@ -125,7 +125,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) const release = vi.fn() - const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) @@ -152,10 +152,12 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: () => null, - removeImage: () => {}, + addFiles: () => null, + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment: () => {}, // Every id resolves so the bar's registry prune never drops a test image. - draftImages: ids => ids.map(id => ({ + resolveDraftAttachments: ids => ids.map(id => ({ kind: 'image' as const, id, file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), previewUrl: `blob:${id}`, @@ -272,16 +274,16 @@ describe('scenario D: execute-kind /compact', () => { describe('scenario: images ride an accepting command through the real pipeline', () => { it('adjudication reports the image count; the claim chain serializes, submits, and consumes', async () => { const b = await bench() - act(() => { b.shell.addImages(['img-1' as DraftAttachmentId]) }) + act(() => { b.shell.addAttachments(['img-1' as DraftAttachmentId]) }) act(() => { b.shell.setDraft('/vision 这张图是什么') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/vision 这张图是什么', [PNG]) }) // The envelope the controller forwarded to matchEnter carried the count. - expect(b.envelopes).toEqual([{ images: 1 }]) + expect(b.envelopes).toEqual([{ attachments: 1 }]) expect(b.serialize).toHaveBeenCalledWith(['img-1']) await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') }) expect(b.release).toHaveBeenCalledWith(['img-1']) - expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.shell.snapshot.attachmentIds).toEqual([]) expect(b.sink).not.toHaveBeenCalled() }) @@ -290,7 +292,7 @@ describe('scenario: images ride an accepting command through the real pipeline', act(() => { b.shell.setDraft('/goal 发布') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) }) - expect(b.envelopes).toEqual([{ images: 0 }]) + expect(b.envelopes).toEqual([{ attachments: 0 }]) expect(b.serialize).not.toHaveBeenCalled() expect(b.release).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 492bc16151..270032799f 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -65,7 +65,7 @@ function liveSession(initial: SessionSnapshot) { } } -const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } +const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) @@ -124,13 +124,24 @@ describe('QueueDock', () => { placement: 'queued' as const, time: 1, text: '等待上传', - images: [{ previewUrl: 'blob:queue-preview', name: 'queue.png' }], + attachments: [ + { type: 'image' as const, previewUrl: 'blob:queue-preview', name: 'queue.png' }, + { + type: 'file' as const, + attachment: { + attachmentId: 'file-local' as never, + name: 'notes.txt', + bytes: 2447 * 1024 * 1024, + }, + }, + ], }], } const source = liveSession(pending) const view = render() expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull() expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview') + expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB') act(() => { source.push({ @@ -282,6 +293,35 @@ describe('QueueDock', () => { expect(container.querySelector('li')?.textContent).toBe('带图消息') }) + it('renders durable files and images in their original queue order', async () => { + const loadImage = vi.fn(() => Promise.resolve('blob:mixed')) + const mixed: QueuedMessage = { + id: iid('i-mixed'), messageId: 'message-i-mixed' as never, placement: 'queued', + content: [ + { + type: 'file', + attachment: { attachmentId: 'file-durable' as never, name: 'report.csv', bytes: 427 }, + }, + { + type: 'image', + attachment: { + attachmentId: 'image-durable' as never, + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }, + ], + preview: '', text: null, + } + const snap = snapshotWith([mixed]) + const source = liveSession(snap) + const view = render() + await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() }) + const group = view.getByLabelText('排队文件 report.csv').parentElement + expect(group?.children).toHaveLength(2) + expect(group?.children[0]?.getAttribute('aria-label')).toBe('排队文件 report.csv') + expect(group?.children[1]?.tagName).toBe('IMG') + }) + it('keeps the empty thumbnail placeholder when the image read fails', async () => { const loadImage = vi.fn(() => Promise.reject(new Error('read denied'))) const snap = snapshotWith([imageRow('i-broken', 'att-x')]) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 7ff7924133..65dd3b7693 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -9,12 +9,14 @@ import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-cl import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' -import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts' +import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' -async function bench() { +async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() - const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + const prompt = vi.fn(( + _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, + ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) @@ -28,6 +30,7 @@ async function bench() { const fiber = runtime.ctx.plugin(ConversationController, { input: hub, blocks: new ComposerBlockRegistry(), + maxConcurrentFileUploads, }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationController @@ -87,13 +90,13 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') - b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id]) + b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id]) await b.runtime.sessions.remove('s1') - expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) expect(revoked).toHaveBeenCalledWith('blob:draft-1') } finally { created.mockRestore() @@ -107,15 +110,15 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') - b.shell.addImages([attachment.id]) + b.shell.addAttachments([attachment.id]) b.shell.submit() - expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.shell.snapshot.attachmentIds).toEqual([]) await b.runtime.sessions.remove('s1') - expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) expect(revoked).toHaveBeenCalledWith('blob:detached') } finally { created.mockRestore() @@ -124,18 +127,310 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('validates every MIME type before allocating previews', async () => { + it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') - expect(() => b.root.createDraftImages([ + const session = b.runtime.sessions.binding('s1')!.session + const uploadFile = vi.fn((_data: Blob | Uint8Array) => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'receipt-1' as never, + file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 }, + }, + })) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const drafts = b.root.createDrafts(session, [ new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), - new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), - ])).toThrow(UnsupportedImageMediaTypeError) - expect(created).not.toHaveBeenCalled() + new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }), + ]) + expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) + expect(created).toHaveBeenCalledTimes(1) + const fileDraft = drafts[1]! + expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading') + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready') + }) + expect(uploadFile).toHaveBeenCalledOnce() + expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file) created.mockRestore() await b.runtime.dispose() }) + it('serializes command files as staged receipts without reading their bytes again', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => + Promise.resolve({ + ok: true as const, + value: { + receiptId: `receipt-${name}` as never, + file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 }, + }, + })) + const drafts = b.root.createDrafts(session, [ + new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }), + new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }), + ]) + await vi.waitFor(() => { + expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) + }) + function RejectingFileReader(): never { + throw new Error('generic file bytes were reread') + } + vi.stubGlobal('FileReader', RejectingFileReader) + try { + await expect(b.root.serializeDraftAttachments(drafts.map(draft => draft.id))).resolves.toEqual({ + attachments: [ + { type: 'file', receiptId: 'receipt-one.txt' }, + { type: 'file', receiptId: 'receipt-two.txt' }, + ], + }) + } finally { + vi.unstubAllGlobals() + } + await b.runtime.dispose() + }) + + it('bounds upload Workers, advances on settlement, and skips a queued file removed by the user', async () => { + const b = await bench(2) + const session = b.runtime.sessions.binding('s1')!.session + type UploadResult = { + ok: true + value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } } + } + const gates = new Map void }>() + let active = 0 + let maxActive = 0 + const uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => { + const fileName = name ?? 'unnamed' + const gate = Promise.withResolvers() + gates.set(fileName, gate) + active += 1 + maxActive = Math.max(maxActive, active) + return gate.promise.finally(() => { active -= 1 }) + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const drafts = b.root.createDrafts(session, ['one', 'two', 'three', 'four', 'removed'].map(name => + new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' }))) + + expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) + await expect(b.root.serializeDraftAttachments([drafts[2]!.id])) + .rejects.toThrow('one or more files have not finished uploading') + b.root.releaseDraftAttachment(drafts[4]!.id) + + const complete = (name: string) => gates.get(name)?.resolve({ + ok: true, + value: { + receiptId: `receipt-${name}` as never, + file: { attachmentId: `file-${name}` as never, name, bytes: 1 }, + }, + }) + complete('one.txt') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(3) }) + complete('two.txt') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(4) }) + complete('three.txt') + complete('four.txt') + await vi.waitFor(() => { + expect(drafts.slice(0, 4).every(draft => + b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) + }) + expect(maxActive).toBe(2) + expect(uploadFile.mock.calls.map(call => call[1])).not.toContain('removed.txt') + expect(b.root.fileUploads.getSnapshot()[drafts[4]!.id]).toBeUndefined() + await b.runtime.dispose() + }) + + it('keeps one upload alive and observable while another Session is open', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + const settled = Promise.withResolvers<{ + ok: true + value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } } + }>() + let reportProgress: ((progress: { loaded: number; total?: number }) => void) | undefined + const uploadFile = vi.fn(( + _file: Blob | Uint8Array, + _name?: string, + _signal?: AbortSignal, + onProgress?: (progress: { loaded: number; total?: number }) => void, + ) => { + reportProgress = onProgress + return settled.promise + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const [attachment] = b.root.createDrafts(session, [ + new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + b.shell.addAttachments([attachment.id]) + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) + + await b.runtime.sessions.add({ + id: 's2', + session: { + prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder, + }, + }) + b.runtime.sessions.open('s2' as never) + reportProgress?.({ loaded: 3, total: 8 }) + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({ + status: 'uploading', loaded: 3, total: 8, + }) + expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id]) + + b.runtime.sessions.open('s1' as never) + settled.resolve({ + ok: true, + value: { + receiptId: 'background-receipt' as never, + file: { attachmentId: 'background-file' as never, name: 'background.bin', bytes: 8 }, + }, + }) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') + }) + expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id]) + await b.runtime.dispose() + }) + + it('cancels a superseded upload and stages the carried draft on the target Session', async () => { + const b = await bench() + const source = b.runtime.sessions.binding('s1')!.session + let sourceSignal: AbortSignal | undefined + const sourceUpload = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => { + sourceSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { + resolve({ ok: false, error: { message: 'aborted' } }) + }, { once: true }) + }) + }) + ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload + const [attachment] = b.root.createDrafts(source, [ + new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() }) + + const target = { + uploadFile: vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'target-receipt' as never, + file: { attachmentId: 'target-file' as never, name: 'carry.pdf', bytes: 1 }, + }, + })), + } + b.root.rebindDraftFiles(target as never, [attachment.id]) + + expect(sourceSignal?.aborted).toBe(true) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({ + status: 'ready', receiptId: 'target-receipt', + file: { attachmentId: 'target-file', name: 'carry.pdf', bytes: 1 }, + }) + }) + await b.runtime.dispose() + }) + + it('cancels a file upload when its draft is removed', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let uploadSignal: AbortSignal | undefined + const uploadFile = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => { + uploadSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { + resolve({ ok: false, error: { message: 'aborted' } }) + }, { once: true }) + }) + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const [attachment] = b.root.createDrafts(session, [ + new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) + + b.root.releaseDraftAttachment(attachment.id) + + expect(uploadSignal?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toBeUndefined() + await b.runtime.dispose() + }) + + it('keeps the accepted file draft until its rpcId appears in the Host queue', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let retire: ((retirement: unknown) => void) | undefined + ;(session as unknown as { beginSubmission: (input: { onRetire?: (retirement: unknown) => void }) => unknown }) + .beginSubmission = (input) => { + retire = input.onRetire + return { requestId: 'file-rpc-id', abandon: vi.fn() } + } + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'send-receipt' as never, + file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 }, + }, + })) + const [attachment] = b.root.createDrafts(session, [ + new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') + }) + + const sending = b.root.sendSession(session, 'read', [attachment.id], 'queue') + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + + expect(b.prompt).toHaveBeenCalledWith([ + { type: 'file', receiptId: 'send-receipt' }, + { type: 'text', text: 'read' }, + ], 'queue', undefined, expect.any(String)) + expect(b.root.resolveDraftAttachments([attachment.id])).toHaveLength(1) + expect(b.prompt.mock.calls[0]?.[3]).toBe('file-rpc-id') + retire?.({ + reason: 'observed', + attachments: [{ attachmentId: 'send-file', name: 'sent.pdf', bytes: 1 }], + }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) + await b.runtime.dispose() + }) + + it('awaits an aborted upload before disposing the service', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let uploadSignal: AbortSignal | undefined + let finishUpload: (() => void) | undefined + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn( + (_data: Uint8Array, _name?: string, signal?: AbortSignal) => { + uploadSignal = signal + return new Promise((resolve) => { + finishUpload = () => { resolve({ ok: false, error: { message: 'aborted' } }) } + }) + }, + ) + b.root.createDrafts(session, [ + new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }), + ]) + await vi.waitFor(() => { expect(uploadSignal).toBeDefined() }) + + let disposed = false + const disposal = b.fiber.dispose().then(() => { disposed = true }) + await vi.waitFor(() => { expect(uploadSignal?.aborted).toBe(true) }) + await Promise.resolve() + expect(disposed).toBe(false) + finishUpload?.() + await disposal + expect(disposed).toBe(true) + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => { const b = await bench() await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) @@ -147,6 +442,7 @@ describe('ConversationController', () => { await bare.plugin(ConversationController, { input: new InputHub(bare, makeTranslate(zh, {})), blocks: new ComposerBlockRegistry(), + maxConcurrentFileUploads: 2, }).await() const orphan = bare.get('conversation') as ConversationController await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) @@ -178,7 +474,7 @@ describe('sendSession submission echo', () => { it('registers the echo before serialization and prompts with its identity', async () => { const b = await echoBench() try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -187,7 +483,7 @@ describe('sendSession submission echo', () => { expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ mode: 'queue', text: '带图', - images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], + attachments: [expect.objectContaining({ type: 'image', previewUrl: 'blob:echo-1', name: 'a.png' })], })) expect(b.prompt).not.toHaveBeenCalled() await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) @@ -201,10 +497,10 @@ describe('sendSession submission echo', () => { 'req-echo', ) // The draft stays registered until the echo's observed retirement. - expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.root.resolveDraftAttachments([attachment!.id])).toHaveLength(1) b.retire.onRetire?.({ reason: 'observed', attachments: [] }) await expect(sending).resolves.toEqual({ kind: 'success' }) - expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([]) expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') } finally { b.restore() @@ -212,6 +508,57 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) + it('preserves mixed image/file selection order through echo, prompt, and observed retirement', async () => { + const b = await echoBench() + try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'mixed-file-receipt' as never, + file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, + }, + })) + const drafts = b.root.createDrafts(session, [ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }), + new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }), + ]) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready') + }) + const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer') + expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'steer', + attachments: [ + expect.objectContaining({ type: 'image', name: 'first.png' }), + { type: 'file', attachment: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, + expect.objectContaining({ type: 'image', name: 'last.png' }), + ], + })) + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt.mock.calls[0]?.[0]).toEqual([ + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'first.png' }, + { type: 'file', receiptId: 'mixed-file-receipt' }, + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'last.png' }, + { type: 'text', text: 'ordered' }, + ]) + b.retire.onRetire?.({ + reason: 'observed', + attachments: [ + { attachmentId: 'image-first', mediaType: 'image/png' }, + { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 }, + { attachmentId: 'image-last', mediaType: 'image/png' }, + ], + }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.resolveDraftAttachments(drafts.map(draft => draft.id))).toEqual([]) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + it('passes each delivery mode before image serialization', async () => { const b = await echoBench() try { @@ -241,17 +588,19 @@ describe('sendSession submission echo', () => { const seedImageUrl = vi.fn(() => true) b.runtime.ctx.provide('uiConversation') b.runtime.ctx.set('uiConversation', { seedImageUrl }) - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) - const ref = { attachmentId: 'att-1' } + const ref = { + attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) await expect(sending).resolves.toEqual({ kind: 'success' }) expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') - expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([]) expect(b.revoked).not.toHaveBeenCalled() // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) @@ -261,20 +610,31 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + it('keeps mixed image and file drafts when the echo retires as failed', async () => { const b = await echoBench() try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'kept-file-receipt' as never, + file: { attachmentId: 'kept-file' as never, name: 'kept.txt', bytes: 1 }, + }, + })) b.prompt.mockResolvedValueOnce({ ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }), } as never) - const [attachment] = b.root.createDraftImages([ + const attachments = b.root.createDrafts(session, [ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }), ]) - const session = b.runtime.sessions.binding('s1')!.session - await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready') + }) + await expect(b.root.sendSession(session, '失败', attachments.map(attachment => attachment.id), 'queue')) .resolves.toEqual({ kind: 'error' }) b.retire.onRetire?.({ reason: 'failed' }) - expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.root.resolveDraftAttachments(attachments.map(attachment => attachment.id))).toHaveLength(2) expect(b.revoked).not.toHaveBeenCalled() } finally { b.restore() @@ -294,7 +654,7 @@ describe('sendSession submission echo', () => { } vi.stubGlobal('FileReader', FailingReader) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -374,15 +734,17 @@ describe('draft image dimension probe', () => { } vi.stubGlobal('Image', InstantImage) try { - const [probed] = b.root.createDraftImages([ + const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), ]) expect(probed).toMatchObject({ width: 640, height: 480 }) vi.stubGlobal('Image', undefined) - const [unprobed] = b.root.createDraftImages([ + const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), ]) - expect(unprobed?.width).toBeUndefined() + expect(unprobed?.kind).toBe('image') + if (unprobed?.kind !== 'image') throw new Error('image draft missing') + expect(unprobed.width).toBeUndefined() } finally { vi.unstubAllGlobals() created.mockRestore() diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 3abf6f1381..71e9715094 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -40,7 +40,7 @@ Range.prototype.getBoundingClientRect = () => ({ function fakeWiring() { const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) return { wiring: shell, sink, shell } } @@ -249,9 +249,11 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} - addImages={() => null} - removeImage={() => {}} - draftImages={() => []} + addFiles={() => null} + useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} + retryFileUpload={undefined} + removeAttachment={() => {}} + resolveDraftAttachments={() => []} resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index af1e72f47e..a300c2a296 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -170,9 +170,9 @@ describe('submit-machine: adjudication outcomes', () => { describe('submit-machine: claimed lifecycle', () => { it('the claim event enters claimed and snapshots hint and images bits', () => { const m = new SubmitMachine() - m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), images: true } }) + m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), attachments: true } }) expect(m.state.phase).toBe('claimed') - expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', images: true }) + expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', attachments: true }) }) it('claimed overwrites in place — no stack', () => { diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 0d081edffc..a1243a7513 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -1074,7 +1074,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 }) + const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { attachments: 0 }) expect(result).toEqual({ claim }) expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast']) }) @@ -1085,7 +1085,7 @@ describe('adjudicate', () => { enterSource('@', 'subagent', atHook), enterSource('/', 'command', () => Promise.resolve(undefined)), ]) - await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined() + await expect(controller.adjudicate('/xyz', new AbortController().signal, { attachments: 0 })).resolves.toBeUndefined() expect(atHook).not.toHaveBeenCalled() }) @@ -1101,7 +1101,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const envelope = { images: 2 } + const envelope = { attachments: 2 } await controller.adjudicate('/goal', new AbortController().signal, envelope) expect(envelopes).toEqual([envelope, envelope]) expect(envelopes[0]).toBe(envelope) @@ -1112,7 +1112,7 @@ describe('adjudicate', () => { enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))), enterSource('/', 'late', () => Promise.resolve('handled')), ]) - await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 })) + await expect(controller.adjudicate('/goal x', new AbortController().signal, { attachments: 0 })) .rejects.toThrow('warmup failed') }) @@ -1121,7 +1121,7 @@ describe('adjudicate', () => { const { controller } = controllerBench([enterSource('/', 'command', hook)]) const abort = new AbortController() abort.abort(new Error('attempt released')) - await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released') + await expect(controller.adjudicate('/goal', abort.signal, { attachments: 0 })).rejects.toThrow('attempt released') expect(hook).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/tests/file-size.client.spec.ts b/packages/client/ui-primitives/tests/file-size.client.spec.ts new file mode 100644 index 0000000000..cdfcb44876 --- /dev/null +++ b/packages/client/ui-primitives/tests/file-size.client.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { fileSizeText } from '../src/file-size.ts' + +describe('fileSizeText', () => { + it('formats bytes and both sides of the KB, MB, and GB rounding thresholds', () => { + expect(fileSizeText(312)).toBe('312B') + expect(fileSizeText(4.25 * 1024)).toBe('4.3KB') + expect(fileSizeText(12.5 * 1024)).toBe('13KB') + expect(fileSizeText(1.5 * 1024 * 1024)).toBe('1.5MB') + expect(fileSizeText(12.5 * 1024 * 1024)).toBe('13MB') + expect(fileSizeText(2447 * 1024 * 1024)).toBe('2.4GB') + expect(fileSizeText(12.5 * 1024 * 1024 * 1024)).toBe('13GB') + }) +}) diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index ebe2b10351..c445cf4bb1 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -389,9 +389,9 @@ describe('DetailsPanel diff Output section', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 032c7c8217..2d815c3f0a 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -311,9 +311,9 @@ describe('DetailsPanel Output section (read)', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index e42e8103b6..36a7ca6661 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -395,9 +395,9 @@ describe('DetailsPanel Output section (search)', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 54ed4542cc..feb13a3c9c 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -524,7 +524,10 @@ describe('DetailsPanel Output section', () => { useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} @@ -712,7 +715,10 @@ describe('DetailsPanel Output section', () => { useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 2732f11aab..68794e0a32 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -243,9 +243,9 @@ describe('DetailsPanel web Output section', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index fbb454decf..5d33b3ecf6 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -666,3 +666,46 @@ describe('durable image attachments', () => { expect(block?.content).toContain('https://example.com/a.png') }) }) + +describe('durable file attachments', () => { + const attachment = { + attachmentId: `sha256:${'d'.repeat(64)}`, + name: 'notes.pdf', + bytes: 2447 * 1024 * 1024, + } + + it('labels file-only and mixed-text user records without rendering file cards', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'file', attachment }, { type: 'file', attachment }], + }, + { + kind: 'user', seq: 2, time: 2_000, source: null, + content: [{ type: 'text', text: 'review these' }, { type: 'file', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const users = turns.flatMap(turn => turn.groups).flatMap(group => group.cells) + .filter(cell => cell.kind === 'user') + expect(users[0]?.text).toBe('Files ×2') + expect(users[0]?.previewMarkdown).toBeUndefined() + expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' }) + }) + + it('keeps image and file counts in a file-only attachment summary', () => { + const image = { + attachmentId: `sha256:${'e'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + } + const nodes = [{ + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', attachment: image }, { type: 'file', attachment }], + }] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 463c84e07e..9d29227274 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -204,13 +204,13 @@ function standaloneProps( ): StandaloneBaseProps { const trajectory = historySnapshot(nodes) const input = createSnapshotStore({ - draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], }) const inputActions: InputActions = { setDraft: () => {}, - addImages: () => false, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => false, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, } return { @@ -326,13 +326,13 @@ function mount(fixture: Awaited>) { createSnapshotStore(tabsOf(slots)), ) const useInput = bindSnapshotSelector(createSnapshotStore({ - draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], })) const inputActions: InputActions = { setDraft: vi.fn(), - addImages: vi.fn(() => false), - removeImage: vi.fn(), - pruneImages: vi.fn(), + addAttachments: vi.fn(() => false), + removeAttachment: vi.fn(), + pruneAttachments: vi.fn(), submit: vi.fn(), } const standardProps = { diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 0667f16376..f6830c98bd 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -88,7 +88,7 @@ const trajectoryState: TrajectoryState = { } const inputState: InputState = { draft: '', - imageIds: [], + attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], @@ -113,9 +113,9 @@ const kit: Omit = { useInput: selector => selector(inputState), inputActions: { setDraft: () => { throw new Error('unused') }, - addImages: () => { throw new Error('unused') }, - removeImage: () => { throw new Error('unused') }, - pruneImages: () => { throw new Error('unused') }, + addAttachments: () => { throw new Error('unused') }, + removeAttachment: () => { throw new Error('unused') }, + pruneAttachments: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, useStore: selector => selector(questionDraftStore.getSnapshot()), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 0ae174caca..3ca590748f 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -87,7 +87,7 @@ const trajectoryState: TrajectoryState = { } const inputState: InputState = { draft: '', - imageIds: [], + attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], @@ -112,9 +112,9 @@ const kitBase: Omit = useInput: selector => selector(inputState), inputActions: { setDraft: () => { throw new Error('unused') }, - addImages: () => { throw new Error('unused') }, - removeImage: () => { throw new Error('unused') }, - pruneImages: () => { throw new Error('unused') }, + addAttachments: () => { throw new Error('unused') }, + removeAttachment: () => { throw new Error('unused') }, + pruneAttachments: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, // The seat's key domain is question ∪ common. diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 3689a7ea89..79e252e5d2 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -318,9 +318,9 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi useInput: () => { throw new Error('unused') }, inputActions: { setDraft: () => {}, - addImages: () => false, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => false, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }, useWorkspaces: selector => selector(panelWorkspace), diff --git a/packages/experimental/inspector/tests/port-selection.host.spec.ts b/packages/experimental/inspector/tests/port-selection.host.spec.ts index 3b400b29f3..1a4f72f6f4 100644 --- a/packages/experimental/inspector/tests/port-selection.host.spec.ts +++ b/packages/experimental/inspector/tests/port-selection.host.spec.ts @@ -1,9 +1,52 @@ /** Host Worker port-selection behavior. */ import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it } from 'vitest' import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +async function closeServer(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve) => { server.close(() => { resolve() }) }) +} + +async function listen(server: Server, port: number): Promise { + return await new Promise((resolve, reject) => { + const onError = (error: Error): void => { reject(error) } + server.once('error', onError) + server.listen(port, '127.0.0.1', () => { + server.off('error', onError) + const address = server.address() + if (address === null || typeof address === 'string') { + reject(new Error('test server did not bind a TCP port')) + return + } + resolve(address) + }) + }) +} + +async function bindWithAvailableSuccessor(): Promise { + for (let attempt = 0; attempt < 32; attempt += 1) { + const candidate = createServer() + const address = await listen(candidate, 0) + if (address.port === 65_535) { + await closeServer(candidate) + continue + } + const probe = createServer() + try { + await listen(probe, address.port + 1) + return candidate + } catch { + await closeServer(candidate) + } finally { + await closeServer(probe) + } + } + throw new Error('test could not reserve an occupied port with a bindable successor') +} + describe('Inspector endpoint port selection', () => { let blocker: Server | undefined let inspector: InspectorHandle | undefined @@ -11,21 +54,12 @@ describe('Inspector endpoint port selection', () => { afterEach(async () => { await inspector?.close() inspector = undefined - if (blocker?.listening === true) { - await new Promise((resolve) => { blocker!.close(() => { resolve() }) }) - } + if (blocker !== undefined) await closeServer(blocker) blocker = undefined }) it('advances from an occupied starting port and publishes the selected port', async () => { - blocker = createServer() - await new Promise((resolve, reject) => { - blocker!.once('error', reject) - blocker!.listen(0, '127.0.0.1', () => { - blocker!.off('error', reject) - resolve() - }) - }) + blocker = await bindWithAvailableSuccessor() const occupiedAddress = blocker.address() if (occupiedAddress === null || typeof occupiedAddress === 'string') { throw new Error('test server did not bind a TCP port') diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts index 9519962309..1aced9e508 100644 --- a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -20,3 +20,19 @@ describe('tunnel init frame', () => { .toThrow(/array of string overlay urls/) }) }) + +describe('tunnel request bodies', () => { + it('accepts ArrayBuffer and Blob bodies and rejects other structured-clone values', () => { + const bytes = Uint8Array.of(1, 2).buffer + const blob = new Blob(['large']) + expect(parseInboundFrame({ + t: 'req', id: 1, method: 'POST', url: '/bytes', headers: {}, body: bytes, + })).toMatchObject({ body: bytes }) + expect(parseInboundFrame({ + t: 'req', id: 2, method: 'POST', url: '/blob', headers: {}, body: blob, + })).toMatchObject({ body: blob }) + expect(() => parseInboundFrame({ + t: 'req', id: 3, method: 'POST', url: '/bad', headers: {}, body: 'large', + })).toThrow('body must be an ArrayBuffer or Blob') + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index bf97dbffa3..92909695c7 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -100,6 +100,15 @@ function stubWorker(): { check('204 resolves with a null body', (await response).body, null) } +// Blob bodies remain Blob-backed across the page-to-Host Worker handoff. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const body = new Blob(['large']) + void tunnel.fetch('/upload', { method: 'POST', body }) + check('a Blob request body stays opaque', (sent[0] as unknown as { body: unknown }).body, body) +} + // A streamed reply reassembles in order and closes. { const { worker, deliver } = stubWorker() diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index 0a32e77753..05597849f3 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { TunnelOutboundFrame } from '../../src/transport/frames.ts' import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts' +function bytes(...values: number[]): Uint8Array { + const data = new Uint8Array(new ArrayBuffer(values.length)) + data.set(values) + return data +} + function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } { const frames: TunnelOutboundFrame[] = [] const server = new TunnelServer({ @@ -60,6 +66,38 @@ describe('worker tunnel unary authentication', () => { }) }) +describe('worker tunnel Blob requests', () => { + it('streams an opaque Blob in bounded chunks inside the Host Worker route', async () => { + const frames: TunnelOutboundFrame[] = [] + const seen: Uint8Array[] = [] + const body = new Blob(['unused']) + body.arrayBuffer = () => Promise.reject(new Error('route must not aggregate the Blob')) + body.stream = () => new ReadableStream>({ + start(controller) { + controller.enqueue(bytes(108, 97, 114)) + controller.enqueue(bytes(103, 101)) + controller.close() + }, + }) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve(async (request, response) => { + for await (const chunk of request as AsyncIterable) seen.push(chunk) + const res = response as { writeHead(status: number): void; end(body: string): void } + res.writeHead(200) + res.end('stored') + }), + }) + server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })())) + server.handleMessage({ + t: 'req', id: 9, method: 'POST', url: 'http://localhost/upload', headers: {}, body, + }) + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['lar', 'ge']) + expect(frames[0]).toMatchObject({ t: 'res', id: 9, status: 200 }) + }) +}) + describe('worker tunnel logical streams', () => { it('drains a pre-boot open through the worker-local Gateway seam', async () => { const { server, frames } = harness() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7c6091b1e4..158fe89475 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -502,6 +502,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', throws: ['an AttachmentError when the durable reference is invalid.'], }, + { + signature: 'saveFile(input: SaveFileAttachment): Promise', + description: 'Durably commit one file byte-for-byte before its owning session event is appended. Files carry no admission limits: any byte content and length is accepted, and the stored object is the exact submitted bytes. Backends without verbatim file storage keep this default rejection.', + parameters: [{ name: 'input', description: 'exact bytes and optional display name.' }], + returns: 'the durable content-addressed file reference.', + }, + { + signature: 'saveFileStream(input: SaveFileStreamAttachment): Promise', + description: 'Durably commit one file byte-for-byte from bounded chunks. Providers must apply backpressure and must not collect the complete file in memory. Backends without streamed verbatim storage keep this default rejection.', + parameters: [{ name: 'input', description: 'ordered exact bytes, optional cancellation, and display name.' }], + returns: 'the durable content-addressed file reference.', + }, + { + signature: 'async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable', + description: 'Read and verify one verbatim stored file as bounded chunks. Providers must not collect the complete file in memory. Backends without verbatim file reads keep this default rejection.', + parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend reads and verification work.' }], + returns: 'exact file bytes in order; integrity failures reject the iteration.', + }, + { + signature: 'fileHostPath(ref: FileAttachmentRef): string | undefined', + description: 'Locate the verbatim stored file object in the harness host filesystem.', + parameters: [{ name: 'ref', description: 'durable file reference.' }], + returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', + throws: ['an AttachmentError when the durable reference is invalid.'], + }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', @@ -625,6 +650,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'definition', description: 'discovery metadata and direct UI handler.' }], returns: 'the exact effect disposer that unregisters this definition.', }, + { + signature: 'registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void', + description: 'Register the sole authority that resolves staged file receipts for command submissions.', + parameters: [{ name: 'resolver', description: 'Session-aware receipt resolver.' }], + returns: 'disposer that removes this exact resolver.', + }, { signature: '@Remote list(agent: Agent): readonly CommandDescriptor[]', description: 'List the effective immutable command descriptors for one agent.', @@ -638,9 +669,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the scoped shadow or global definition.', }, { - signature: '@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise', - description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nImage admission is enforced here, not in the composer: images sent to a command that does not declare `input.images`, an absent attachment store, and an exceeded attachment limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object.', - parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'images', description: 'base64-encoded composer images accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], + signature: '@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise', + description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nAttachment admission is enforced here, not in the composer: attachments sent to a command that does not declare `input.attachments`, an absent attachment store, and an exceeded image limit each settle as an error result before the handler runs. Validation rejection starts no attachment writes; a storage failure can leave only unreachable content-addressed objects for deferred collection.', + parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'submittedAttachments', description: 'encoded images and staged file receipts accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], returns: 'the settled execution (result + lifecycle pairing id), or `undefined` when syntax or name does not resolve.', }, ], @@ -1154,6 +1185,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'provider', description: 'provider route named by a request header.' }, { name: 'model', description: 'exact model id named by the same header.' }], returns: 'the owning adapter\'s image pricing for the route, when declared.', }, + { + signature: 'fileRequestText(ref: FileAttachmentRef): string', + description: 'Resolve the exact text one durable file occurrence contributes to every provider request in the current execution environment.', + parameters: [{ name: 'ref', description: 'durable verbatim file reference from model history.' }], + returns: 'the same deterministic handle text used at adapter dispatch.', + }, { signature: 'async listModels(provider: string): Promise', description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', @@ -1413,6 +1450,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session and attachment identities used for authorization.' }], returns: 'the durable attachment reference and base64-encoded bytes.', }, + { + signature: '@Remote(\'uploadFile\') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise', + description: 'Persist one encoded file upload verbatim and stage it for a later prompt on the same Session.', + parameters: [{ name: 'request', description: 'Session identity, base64 payload, and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }], + returns: 'an opaque per-upload receipt and the durable file reference.', + }, { signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue', description: 'Mutate one still-pending queue occurrence on a live Agent.', @@ -3670,22 +3713,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CommandExecution', declaration: 'export interface CommandExecution {\n readonly commandId: CommandId;\n readonly result: CommandResult;\n}', }, + { + name: 'CommandFileReceiptResolver', + declaration: 'export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined;', + }, { name: 'CommandId', declaration: 'export type CommandId = Branded<\'CommandId\'>;', }, { name: 'CommandInputDescriptor', - declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly images?: boolean;\n}', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly attachments?: boolean;\n}', }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly ImageBlock[];\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly (ImageBlock | FileBlock)[];\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: number;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, + { + name: 'CommandSubmitAttachment', + declaration: 'export type CommandSubmitAttachment = ({\n readonly type: \'image\';\n} & EncodedImageAttachment) | {\n readonly type: \'file\';\n readonly receiptId: string;\n};', + }, { name: 'CompactionAgentContext', declaration: 'export interface CompactionAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', @@ -3716,7 +3767,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContentBlockMap', - declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', + declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'file\': FileBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', }, { name: 'ContentBlockType', @@ -3998,6 +4049,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FiberState', declaration: 'export type FiberState = FiberStateEnum;', }, + { + name: 'FileAttachmentRef', + declaration: 'export interface FileAttachmentRef {\n attachmentId: AttachmentId;\n name: string;\n bytes: number;\n}', + }, + { + name: 'FileBlock', + declaration: 'export interface FileBlock {\n type: \'file\';\n attachment: FileAttachmentRef;\n}', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', @@ -4010,6 +4069,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileReferenceCandidate', declaration: 'export interface FileReferenceCandidate {\n path: string;\n kind: \'file\' | \'directory\';\n}', }, + { + name: 'FileUploadReceiptId', + declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', @@ -4328,7 +4391,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmRuntime', - declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n fileRequestText(ref: FileAttachmentRef): string;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions) /* …truncated — full shape in source */', }, { name: 'LspHover', @@ -4734,6 +4797,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SaveFileAttachment', + declaration: 'export interface SaveFileAttachment {\n data: Uint8Array;\n name?: string;\n}', + }, + { + name: 'SaveFileStreamAttachment', + declaration: 'export interface SaveFileStreamAttachment {\n data: AsyncIterable;\n signal?: AbortSignal;\n name?: string;\n}', + }, { name: 'SaveImageAttachment', declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', @@ -5198,6 +5269,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionUpdateQueueValue', declaration: 'export interface SessionUpdateQueueValue {\n readonly accepted: true;\n}', }, + { + name: 'SessionUploadFileRequest', + declaration: 'export interface SessionUploadFileRequest {\n readonly sessionId: SessionId;\n readonly data: string;\n readonly name?: string;\n}', + }, + { + name: 'SessionUploadFileValue', + declaration: 'export interface SessionUploadFileValue {\n readonly receiptId: FileUploadReceiptId;\n readonly file: FileAttachmentRef;\n}', + }, { name: 'SessionWireEvent', declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}', diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1f1cdf0753..ed53189f7d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]', images: true }, + input: { hint: '[|clear|edit |pause|resume]', attachments: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() @@ -236,7 +236,7 @@ describe('/goal human command', () => { }) }) -describe('/goal image attachments', () => { +describe('/goal attachments', () => { const PNG = 'AAAA' /** Wire the fake store the executor admits through (once per harness). */ @@ -261,25 +261,35 @@ describe('/goal image attachments', () => { for (const input of inputs) refs.push(await saveImage(input)) return refs }, + saveFile(input: { data: Uint8Array; name?: string }) { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }, }) + test.ctx.commands.registerFileReceiptResolver((_agent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: 'file-notes' as never, bytes: 5, name: 'notes.txt' } + : undefined) } - /** Run /goal with `count` composer images through the executor boundary. */ - async function runWithImages(test: Harness, suffix: string, count: number) { - const images = Array.from({ length: count }, (_, index) => ({ - mediaType: 'image/png' as const, data: PNG, name: `ref-${index + 1}.png`, - })) - const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, images, new AbortController().signal) + /** Run /goal with a mixed composer batch through the executor boundary. */ + async function runWithAttachments(test: Harness, suffix: string, includeFile = true) { + const attachments = [ + { type: 'image' as const, mediaType: 'image/png' as const, data: PNG, name: 'ref.png' }, + ...(includeFile ? [{ type: 'file' as const, receiptId: 'receipt-notes' }] : []), + ] + const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, attachments, new AbortController().signal) if (execution === undefined) throw new Error('goal command was not registered') return execution.result } - it('submits one user followup carrying the admitted images ahead of the round prompt', async () => { + it('submits one user followup carrying mixed attachments ahead of the round prompt', async () => { const test = await harness() provideStore(test) const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup - const result = await runWithImages(test, ' rebuild the cathedral', 2) + const result = await runWithAttachments(test, ' rebuild the cathedral') expect(result.kind).toBe('success') expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as { @@ -287,9 +297,10 @@ describe('/goal image attachments', () => { source: { kind: string } } expect(message.source).toEqual({ kind: 'user' }) - expect(message.content.map(block => block.type)).toEqual(['image', 'image', 'text']) - expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference images for the goal objective.' }) - expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref-1.png') + expect(message.content.map(block => block.type)).toEqual(['image', 'file', 'text']) + expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference attachments for the goal objective.' }) + expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref.png') + expect((message.content[1] as { attachment: { name: string } }).attachment.name).toBe('notes.txt') }) it('accompanies an edit and a post-complete recreate the same way', async () => { @@ -298,7 +309,7 @@ describe('/goal image attachments', () => { const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'initial objective' }) - const result = await runWithImages(test, ' edit refined objective', 1) + const result = await runWithAttachments(test, ' edit refined objective') expect(result.kind).toBe('success') expect(followup).toHaveBeenCalledTimes(1) }) @@ -310,10 +321,10 @@ describe('/goal image attachments', () => { ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'active objective' }) for (const suffix of [' pause', '', ' clear']) { - const result = await runWithImages(test, suffix, 1) + const result = await runWithAttachments(test, suffix, false) expect(result).toEqual({ kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', }) } expect(followup).not.toHaveBeenCalled() @@ -326,7 +337,7 @@ describe('/goal image attachments', () => { const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'existing objective' }) - const result = await runWithImages(test, ' replacement objective', 1) + const result = await runWithAttachments(test, ' replacement objective') expect(result.kind).toBe('error') expect(followup).not.toHaveBeenCalled() }) diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 655cdc5462..494d2c4b9c 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -465,7 +465,7 @@ describe('CommandRuntime', () => { }) }) -describe('image attachments', () => { +describe('command attachments', () => { const PNG = 'AAAA' function storeOf() { @@ -483,6 +483,12 @@ describe('image attachments', () => { ...input.name === undefined ? {} : { name: input.name }, }) }), + saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }), validateImageBatch(inputs: readonly unknown[]) { const validate = AttachmentStore.prototype as unknown as { validateImageBatch(this: unknown, batch: readonly unknown[]): void @@ -500,8 +506,8 @@ describe('image attachments', () => { function accepting(handler: CommandDefinition['handler']): CommandDefinition { return { name: 'vision', - description: 'accepts images', - input: { hint: '', images: true }, + description: 'accepts attachments', + input: { hint: '', attachments: true }, handler, } } @@ -510,32 +516,32 @@ describe('image attachments', () => { const ctx = await mount() expect(() => ctx.commands.register({ ...command('flag-type'), - input: { hint: 'x', images: 'yes' }, - } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean') + input: { hint: 'x', attachments: 'yes' }, + } as unknown as CommandDefinition)).toThrow('command "flag-type" input attachments flag must be a boolean') }) - it('lists images acceptance on the descriptor and omits a false flag', async () => { + it('lists attachment acceptance on the descriptor and omits a false flag', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) - ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } }) + ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', attachments: false } }) const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor])) - expect(byName.get('vision')?.input).toEqual({ hint: '', images: true }) + expect(byName.get('vision')?.input).toEqual({ hint: '', attachments: true }) expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' }) }) - it('settles images sent to a non-declaring command as a logged error before the handler', async () => { + it('settles attachments sent to a non-declaring command as a logged error before the handler', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register({ ...command('deploy'), handler }) const execution = await ctx.commands.execute( - agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) - expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' }) + agent, '/deploy now', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept attachments' }) expect(handler).not.toHaveBeenCalled() expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'deploy' } }, - { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } }, + { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept attachments' } }, ]) }) @@ -544,41 +550,79 @@ describe('image attachments', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) const execution = await ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', - text: '/vision: image attachments are unavailable because no attachment store is composed', + text: '/vision: attachments are unavailable because no attachment store is composed', }) }) - it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => { + it('admits a mixed batch in selection order and keeps plain invocations empty', async () => { const ctx = await mount() ctx.provide('attachments', storeOf()) const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.registerFileReceiptResolver((_receivingAgent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: 'file-notes' as never, name: 'notes.txt', bytes: 5 } + : undefined) const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => { expect(Object.isFrozen(invocation.attachments)).toBe(true) return { kind: 'success' as const } }) ctx.commands.register(accepting(seen)) await ctx.commands.execute(agent, '/vision x', [ - { mediaType: 'image/png', data: PNG, name: 'a.png' }, - { mediaType: 'image/png', data: PNG, name: 'b.png' }, + { type: 'image', mediaType: 'image/png', data: PNG, name: 'a.png' }, + { type: 'file', receiptId: 'receipt-notes' }, + { type: 'image', mediaType: 'image/png', data: PNG, name: 'b.png' }, ], new AbortController().signal) const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> } expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([ - ['image', 'a.png'], ['image', 'b.png'], + ['image', 'a.png'], ['file', 'notes.txt'], ['image', 'b.png'], ]) await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal) expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([]) }) + it('requires a same-session file receipt resolver and keeps its registration single-owner', async () => { + const ctx = await mount() + const store = storeOf() + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + const missing = await ctx.commands.execute( + agent, '/vision x', [{ type: 'file', receiptId: 'missing' }], new AbortController().signal) + expect(missing?.result).toEqual({ + kind: 'error', text: 'File upload receipt is unknown for this session.', + }) + expect(handler).not.toHaveBeenCalled() + const mixed = await ctx.commands.execute(agent, '/vision x', [ + { type: 'image', mediaType: 'image/png', data: PNG }, + { type: 'file', receiptId: 'missing' }, + ], new AbortController().signal) + expect(mixed?.result).toEqual({ + kind: 'error', text: 'File upload receipt is unknown for this session.', + }) + expect(store.saveImage).not.toHaveBeenCalled() + + const first = vi.fn(() => undefined) + const disposeFirst = ctx.commands.registerFileReceiptResolver(first) + expect(() => ctx.commands.registerFileReceiptResolver(() => undefined)) + .toThrow('commands: a file receipt resolver is already registered') + disposeFirst() + const disposeSecond = ctx.commands.registerFileReceiptResolver(() => undefined) + disposeFirst() + expect(() => ctx.commands.registerFileReceiptResolver(() => undefined)) + .toThrow('commands: a file receipt resolver is already registered') + disposeSecond() + }) + it('settles an admission limit failure as a logged error result', async () => { const ctx = await mount() ctx.provide('attachments', storeOf()) const { agent } = await mintAgentScope(ctx, 'a') const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register(accepting(handler)) - const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG })) + const three = [1, 2, 3].map(() => ({ type: 'image' as const, mediaType: 'image/png' as const, data: PNG })) const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', text: 'Image batch exceeds the configured image-count limit.' }) expect(handler).not.toHaveBeenCalled() @@ -600,7 +644,7 @@ describe('image attachments', () => { const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register(accepting(handler)) await expect(ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal, + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], controller.signal, )).rejects.toThrow('operator cancelled during admission') expect(handler).not.toHaveBeenCalled() expect(lifecycleOf(agent).at(-1)).toMatchObject({ @@ -617,7 +661,7 @@ describe('image attachments', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) await expect(ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal, + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal, )).rejects.toThrow('disk gone') expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index cd53724e34..0d2dded443 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -620,7 +620,7 @@ describe('/plan', () => { const plainSteer = vi.fn() ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer expect(ctx.commands.list(plainAgent)).toEqual([ - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ]) const signal = new AbortController().signal @@ -704,7 +704,7 @@ describe('/plan', () => { expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) - it('steers image attachments with or without text and refuses them on /plan off', async () => { + it('steers mixed attachments with or without text and refuses them on /plan off', async () => { const ctx = await setup() await ctx.plugin(CommandRuntime) await new Promise(resolve => setImmediate(resolve)) @@ -727,21 +727,34 @@ describe('/plan', () => { for (const input of inputs) refs.push(await saveImage(input)) return refs }, + saveFile(input: { data: Uint8Array; name?: string }) { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }, }) + ctx.commands.registerFileReceiptResolver((agent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: `file-${agent.id}` as never, bytes: 5, name: 'notes.txt' } + : undefined) const signal = new AbortController().signal - const images = [{ mediaType: 'image/png' as const, data: 'AAAA' }] + const attachments = [ + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AAAA', name: 'diagram.png' }, + { type: 'file' as const, receiptId: 'receipt-notes' }, + ] const agent = await agentWithSession(ctx, 'imaged-plan-command') openTurn(agent.session) const steer = vi.fn() ;(agent as unknown as { steer: typeof steer }).steer = steer - const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', images, signal) + const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', attachments, signal) expect(withMessage?.result.kind).toBe('success') expect(steer).toHaveBeenCalledExactlyOnceWith({ id: expect.any(String) as unknown, role: 'user', content: [ { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-1' }) as unknown }, + { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-plan-command', name: 'notes.txt' }) as unknown }, { type: 'text', text: 'sketch the layout' }, ], source: { kind: 'user' }, @@ -751,12 +764,15 @@ describe('/plan', () => { openTurn(bareAgent.session) const bareSteer = vi.fn() ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer - expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result) + expect((await ctx.commands.execute(bareAgent, '/plan', attachments, signal))?.result) .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' }) expect(bareSteer).toHaveBeenCalledExactlyOnceWith({ id: expect.any(String) as unknown, role: 'user', - content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }], + content: [ + { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }, + { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-bare-plan-command', name: 'notes.txt' }) as unknown }, + ], source: { kind: 'user' }, }) expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true }) @@ -764,8 +780,8 @@ describe('/plan', () => { const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true }) const offSteer = vi.fn() ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer - expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result) - .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' }) + expect((await ctx.commands.execute(activeAgent, '/plan off', attachments, signal))?.result) + .toEqual({ kind: 'error', text: 'Attachments cannot accompany /plan off.' }) expect(offSteer).not.toHaveBeenCalled() expect(ctx.planMode.get(activeAgent)).toEqual({ active: true }) }) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 4d542a8bc4..120b0c5304 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -53,7 +53,7 @@ afterEach(async () => { for (const root of roots.splice(0)) { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } -}) +}, 30_000) interface RealHarness { readonly ctx: Context diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index d16175f9b5..db03ef8f0a 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -428,6 +428,7 @@ describe('fixture session face', () => { const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) expect(() => bare.readAttachment('att-1' as Parameters[0])).toThrow(/readAttachment is not stubbed/) + expect(() => bare.uploadFile()).toThrow(/uploadFile is not stubbed/) expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 4c88111de5..33750560f2 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -895,9 +895,9 @@ describe('runScenario', () => { it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => { const missing = await scenario({}) await expect(runScenario( - { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] }, + { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 200 }] }, { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/end within 20ms/) + )).rejects.toThrow(/did not persist turn\/end within 200ms/) const open = await scenario({ prompt: 'hang-until-cancel', @@ -915,11 +915,11 @@ describe('runScenario', () => { steps: [ ...boot, { op: 'promptAndCancel', text: 'hang' }, - { op: 'waitForTurnEnd', timeoutMs: 20 }, + { op: 'waitForTurnEnd', timeoutMs: 200 }, ], }, { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/end within 20ms/) + )).rejects.toThrow(/did not persist turn\/end within 200ms/) }) it('waitForGoalPhase requires the requested durable goal phase', { timeout: 20_000 }, async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da090f24c2..7e8b38bdf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,6 +783,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a6fa86fe40..4dcffa2c05 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -337,6 +337,8 @@ export const LINK_MAP: Readonly> = { SessionSelectModelValue: 'session.md', SessionSummary: 'session.md', SessionUpdateQueueRequest: 'session.md', + SessionUploadFileRequest: 'session.md', + SessionUploadFileValue: 'session.md', SessionUpdateQueueValue: 'session.md', SessionStartSource: 'core.md', SessionLogSnapshot: 'session-query.md', @@ -347,7 +349,11 @@ export const LINK_MAP: Readonly> = { ApprovalRequestEvent: 'approval.md', ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', + EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', + FileAttachmentRef: 'attachment.md', + SaveFileAttachment: 'attachment.md', + SaveFileStreamAttachment: 'attachment.md', ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', @@ -392,8 +398,10 @@ export const LINK_MAP: Readonly> = { CreateGoalResult: 'goal.md', CommandDefinition: 'commands.md', CommandDescriptor: 'commands.md', + CommandFileReceiptResolver: 'commands.md', CommandId: 'commands.md', CommandResult: 'commands.md', + CommandSubmitAttachment: 'commands.md', CommandSurface: 'commands.md', LspProvider: 'lsp.md', LspQueryRequest: 'lsp.md', diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts index b348d514ab..fdb0962f04 100644 --- a/scripts/verify-client-ui-i18n.ts +++ b/scripts/verify-client-ui-i18n.ts @@ -35,8 +35,11 @@ const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Pl const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|message|placeholder|summary|text|title|tooltip)(?:s|_.*)?$/i const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|message|placeholder|summary|text|title|tooltip|tabs)$/i const IMMUTABLE_LANGUAGE_TOKENS = new Set([ + 'B', 'Function', + 'GB', 'K', + 'KB', 'M', 'MB', 'Symbol', diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index e633f1ca1c..fb3437d266 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -29,6 +29,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 78605cc160..07b054429a 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -117,6 +117,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 5b20a1e993..4202ac6ef7 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -47,6 +47,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 89f5b6f42d..9d1a40d048 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -39,6 +39,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 5b753960a5..4f88197939 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -47,6 +47,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 057d13cd9c..ed35656abe 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -39,6 +39,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/file-upload-round/replay.override.json b/snapshots/web/file-upload-round/replay.override.json new file mode 100644 index 0000000000..388ed168eb --- /dev/null +++ b/snapshots/web/file-upload-round/replay.override.json @@ -0,0 +1,43 @@ +{ + "patches": [ + { + "at": 0, + "entry": { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "call_00_ET_x83IHPfS8ouwILXe8u729956", + "name": "read", + "arguments": "{\"file_path\": \"{{fromRequest:saved at \\\"([^\\\"]+)\\\"}}\"}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 8243, + "outputTokens": 147, + "totalTokens": 8390, + "cacheReadTokens": 0, + "reasoningTokens": 0 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + } + } + ] +} diff --git a/snapshots/web/file-upload-round/session.jsonl b/snapshots/web/file-upload-round/session.jsonl new file mode 100644 index 0000000000..7a481908b7 --- /dev/null +++ b/snapshots/web/file-upload-round/session.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787887026413,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"model/selection","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Read the attached file with","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_x83IHPfS8ouwILXe8u729956"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_x83IHPfS8ouwILXe8u729956","content":[{"type":"text","text":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\nfile\n\n1: UPLOAD_ROUND_OK\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt","offset":1,"lines":[{"number":1,"text":"UPLOAD_ROUND_OK"}],"totalLines":1}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"text-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,15,17,15,17],"texts":["U","PL","O","AD","_RO","UND","_OK"]}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UPLOAD_ROUND_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UPLOAD_ROUND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}},"sourceEventSeqs":[22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/file-upload-round/snapshot.yml b/snapshots/web/file-upload-round/snapshot.yml new file mode 100644 index 0000000000..b0e3a4a6e3 --- /dev/null +++ b/snapshots/web/file-upload-round/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: file-upload-round +profile: web +composition: web-default +recording: live +header: + class: web-default +replay: + override: true diff --git a/snapshots/web/file-upload-round/trajectory.expected.md b/snapshots/web/file-upload-round/trajectory.expected.md new file mode 100644 index 0000000000..54cde508ec --- /dev/null +++ b/snapshots/web/file-upload-round/trajectory.expected.md @@ -0,0 +1,3 @@ +- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": + - cell "Turn 1 USER": USER + - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md new file mode 100644 index 0000000000..2541ec1c4a --- /dev/null +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "Read the attached file with" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: poem.txt TXT 16B +- button "reference-1.png, click to view original": + - img "reference-1.png" +- text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} +- button "Copy": + - img +- button "1 tool call": + - text: 1 tool call + - img +- paragraph: UPLOAD_ROUND_OK +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- button "Usage 17.3K tok": + - img + - text: Usage 17.3K tok +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- button "Add attachment": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high": + - text: DeepSeek-V4-Flash-Vision-Exp high + - img +- button "1% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 17.2K tok · Output 155 tok diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 108ee4050c..b65f5d2ae3 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 9a83925d23..f0449b34f2 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 700f499256..aeb9ecd7a9 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -229,6 +229,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index fd874dc327..eeaf7d1cca 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -117,6 +117,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 2d9f315f43..a4694918d4 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -33,6 +33,8 @@ - paragraph - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 5f53d73cfd..e886dd913e 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -32,6 +32,8 @@ - textbox "Describe what you want to build... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 00d271cff3..6cde9ddcba 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index bc5558ab03..dff102b7cb 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index b61cd9e51a..635ddf6d9f 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -40,6 +40,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 4dbfb09b4a..84320a45d0 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -36,6 +36,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index ae854c557c..e5b3c6ca64 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -26,6 +26,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 4307a62de2..a16ba7d1ee 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -25,6 +25,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 7dc6c9f613..237543a043 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -28,6 +28,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 1c6a87f6d1..3919ce3dfc 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -45,6 +45,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 93107fe00b..1cfe5382f5 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 6f8f2cd629..fa61237f73 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -26,6 +26,8 @@ - paragraph: Queue this follow-up while the current turn is running. - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 53bfe16b3b..136f209f56 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -69,6 +69,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index e26d5cdb11..91a71e8e57 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -40,6 +40,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 0233cfa370..58abbd47bd 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -61,6 +61,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 14ba8a416e..cc7b07f86d 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 6e590dabd7..f5b102fcad 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -56,6 +56,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index b2f7ba6294..34c5652e9a 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -53,6 +53,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index a6dc5b28fd..1a879c04c5 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 0c18353ee1..d6f6be3bba 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -32,6 +32,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 9ce8b0be96..0b3e7b65b0 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -26,6 +26,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 4c64c771f0..f32a37c548 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -42,6 +42,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 666cb935b7..0e0835f2bb 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -42,6 +42,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 5a5143fc2c..041650fb7b 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -59,6 +59,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 9798e5da4f..e9a74f7b05 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -55,6 +55,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 48c85e44f4..e314229b31 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index b5dc457a4e..23fad54c88 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -79,6 +79,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 462b6d03d1..457affb944 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -35,6 +35,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 3f4ed25eb3..3e9691ff71 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -61,6 +61,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 1265a3e50e..57505c708b 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -65,6 +65,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 2d18d8ec62..70c6204338 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -59,6 +59,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 4711ff7304..7ea48e69f6 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index cff07a3aae..74903e7e6e 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -54,6 +54,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index bfd48e7ed9..5c2738c768 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -46,6 +46,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 0d190d89e9..237bab0dfc 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 2cbe5b1fa8..716f7f6d47 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -78,6 +78,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 38b587e257..746d68c658 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -66,6 +66,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 4455ad4ad6..7c00b9e2c8 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -27,6 +27,8 @@ - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] - button "Commands" [disabled]: - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" - button "Send message" [disabled] diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 637055a274..ed25b8d556 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 94870d19c8..0c2c806aad 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index d79602b47c..502147691d 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 26bd7a6d83..5aff82d1a1 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -37,6 +37,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 43d984fc53..a01ee4fd78 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 0968ee2b75..5ab42e660c 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash From 590a8e220abaa3cba7c20860f5640a9f4d3e3edc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:39:02 +0800 Subject: [PATCH 06/37] docs(attachment): record generic file behavior --- ...08-17-command-image-attachment-envelope.md | 46 ------------------ ...17-command-image-attachment-envelope.zh.md | 46 ------------------ ... 2026-08-26-generic-file-upload.i18n.yaml} | 6 +-- .../feature/2026-08-26-generic-file-upload.md | 47 +++++++++++++++++++ .../2026-08-26-generic-file-upload.zh.md | 47 +++++++++++++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 6 +-- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 14 +++--- docs/event-producer-consumer.zh.md | 12 ++--- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 37 +++++++++++++++ docs/subsystems/attachment.zh.md | 37 +++++++++++++++ docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 39 +++++++++------ docs/subsystems/commands.zh.md | 39 +++++++++------ docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 13 ++++- docs/subsystems/llm-streaming.zh.md | 13 ++++- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 9 ++++ docs/subsystems/session.zh.md | 9 ++++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 7 +-- packages/api/session-controller/README.zh.md | 7 +-- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 9 ++-- .../attachment/attachment-local/README.zh.md | 9 ++-- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 27 ++++++----- packages/attachment/attachment/README.zh.md | 29 +++++++----- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 6 +-- packages/client/connection/README.zh.md | 6 +-- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 17 ++++--- packages/client/ui-attachment/README.zh.md | 17 ++++--- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 4 +- packages/client/ui-commands/README.zh.md | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +-- packages/client/ui-conversation/README.zh.md | 6 +-- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 4 +- packages/client/ui-trajectory/README.zh.md | 4 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- packages/goal/command-goal/README.i18n.yaml | 4 +- packages/goal/command-goal/README.md | 12 ++--- packages/goal/command-goal/README.zh.md | 12 ++--- .../interaction/commands/README.i18n.yaml | 4 +- packages/interaction/commands/README.md | 14 +++--- packages/interaction/commands/README.zh.md | 14 +++--- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 3 +- packages/llm/llm/README.zh.md | 3 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 6 +-- packages/llm/token-meter/README.zh.md | 6 +-- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 10 ++-- packages/plan/plan-mode/README.zh.md | 10 ++-- .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 2 +- .../session-log-export/README.zh.md | 2 +- 74 files changed, 472 insertions(+), 318 deletions(-) delete mode 100644 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md delete mode 100644 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md rename .agents/notes/implemented/feature/{2026-08-17-command-image-attachment-envelope.i18n.yaml => 2026-08-26-generic-file-upload.i18n.yaml} (55%) create mode 100644 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md create mode 100644 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md deleted file mode 100644 index 328a3fffa1..0000000000 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Command image-attachment envelope - -Status: implemented - -English | [中文](2026-08-17-command-image-attachment-envelope.zh.md) - -## Problem - -The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest. - -Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork. - -## Decision - -The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly. - -**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot. - -**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics. - -**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. - -**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan ` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit. - -**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. - -## Testing - -Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes. - -## Alternatives considered - -- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all. -- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule. -- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility. -- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. -- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself. -- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`. - -## Consequences - -- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike. -- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. -- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt. -- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. -- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written". diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md deleted file mode 100644 index 9de197d066..0000000000 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Command image-attachment envelope - -Status: implemented - -[English](2026-08-17-command-image-attachment-envelope.md) | 中文 - -## Problem - -Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。 - -合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.zh.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。 - -## Decision - -提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。 - -**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。 - -**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。 - -**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 - -**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan ` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。 - -**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 - -## Testing - -注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。 - -## Alternatives considered - -- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。 -- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。 -- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。 -- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 -- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。 -- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。 - -## Consequences - -- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。 -- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 -- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。 -- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 -- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 6851450147..c41fb5319b 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md -2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9 -2026-08-17-command-image-attachment-envelope.zh.md: 9de197d06692523b7b5bca4f7aac36ce9f44b105 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +2026-08-26-generic-file-upload.md: 91f331cfc43cdfd771953b27baffb712eb4cbd19 +2026-08-26-generic-file-upload.zh.md: 46c62750be7331acc384cb8c5271ef49cedb73f4 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md new file mode 100644 index 0000000000..91f331cfc4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -0,0 +1,47 @@ +# Agent Note: Generic file upload + +Status: implemented + +English | [中文](2026-08-26-generic-file-upload.zh.md) + +## Problem + +Sessions accepted pasted or dropped images as durable attachments, but no other file kind could enter a conversation: the composer refused non-image bytes, and the only file affordance was the `@` path mention, which requires the file to already be readable from the host workspace. A user could not hand the agent a PDF, a data file, or an archive from a browser, and DeepSeek Chat users expect the paperclip-and-file-card upload flow (#2984). + +The command plane had already established a whole-envelope rule for images: a command consumes the text and attachments together or visibly refuses the submission. Its `input.images` declaration and image-only RPC payload made generic files a separate refusal path even after ordinary messages could carry them. Plan and goal entry therefore needed the same attachment vocabulary without weakening executor enforcement or silently clearing a draft. + +## Decision + +Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. Served Web pages hand each active `File` to a dedicated upload Worker, which sends raw bytes and reports byte progress; Worker-hosted pages clone the opaque `Blob` to the existing Host Worker, which reads its stream in bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. + +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. The Host validates every file receipt before persisting any image, rejects upload completion after the original Agent is disposed, and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. + +## Alternatives considered + +- **Store uploads in the project root** (the OpenHands / ChatGPT `/mnt/data` shape). Rejected: files in the user's tree can be edited, moved, or deleted, which breaks the session log's stable reference, pollutes `git status`, and requires a workspace to exist. `DSH_HOME` keeps frozen read-only bytes with content addressing; execution-world visibility is served by the projected path and can gain a copy-into-workspace step later. +- **A dedicated `read_file` tool.** Rejected: the handle line points at an ordinary path, so the existing `read` tool (and the model's other file tools) already covers it; a second read tool would split model behavior without adding capability. +- **Eager content injection into the prompt.** Rejected for files exactly as it was for `@` mentions ([web file references](2026-07-27-web-file-and-session-references.md)): relevance is unknown at attach time, and lazy tool reads keep every content access logged. +- **Type whitelist and size caps.** Rejected for now: DeepSeek Chat's whitelist exists because its server parses uploads, while this harness only stores bytes and lets the model read them. The streaming carrier and store keep memory proportional to bounded chunks, so a cap would govern disk and transfer policy rather than request safety. +- **A new attachment/file session event.** Rejected: the block-inside-`user/message` pattern images already use satisfies model-visible ⟺ logged without widening the event vocabulary. +- **Block every command while attachments are present.** Rejected: `/goal` objectives and `/plan` entry need reference files, so a global refusal would leave those files with no command route to the model. +- **Automatically send attachments after any successful command.** Rejected: state commands such as `/model` and `/compact` do not imply model-visible input. The command producer owns whether an admitted batch becomes a user message. +- **Store objective attachments in the goal domain.** Rejected: one ordinary logged user message gives later goal rounds the same history without widening the goal schema or duplicating attachments into round prompts. +- **Consume attachments on any command success.** Rejected: `/goal pause` or `/plan off` would clear unrelated cards. Consumption follows the producer's explicit success; grammar forms that cannot use attachments return an error. +- **Enforce declarations only in the client.** Rejected: direct RPC callers can bypass the composer. `CommandRuntime` enforces declaration, store availability, same-Session file-receipt resolution, image base64 admission, and image limits before entering the handler. + +## Consequences + +- Uploaded bytes are never deleted and carry no size limit, so `DSH_HOME` growth is unbounded; garbage collection stays deferred with the image-retention question. +- The projected path is execution-environment-local: a sandboxed or remote filesystem that cannot map `DSH_HOME` yields an explicit no-path handle, and the model reports that it cannot read the file. A store-backed read fallback is deferred. +- Staged uploads live in Host memory per Session; a Host restart between upload and send surfaces `FILE_NOT_STAGED` and the client re-adds the file. +- The browser-to-Host transfer is one streaming HTTP request without resumable offsets; retrying after connection or Host failure retransmits the file from byte zero. +- Commands must declare attachment acceptance. Rejected commands preserve the text draft and every attachment card for retry. +- Subagents do not upload files independently. A main agent delegates with the saved path; only same-machine or inherited-history children whose execution environment maps that path can read it. +- Uploaded files do not appear in `@` completion yet; that integration is a planned later phase. +- The command registry admits a tagged `CommandSubmitAttachment` union whose file member carries a staged receipt, then hands handlers one frozen ordered `ImageBlock | FileBlock` array. The registry never schedules model input; `/goal` adds one logged follow-up and `/plan` steers the ordered blocks. +- Menu popup selection does not submit the composer envelope. Attachments remain visible until the user submits a command line or removes them. +- Declaration, missing-store, unknown-receipt, and image-limit rejections occur before handler entry. A producer grammar rejection and cancellation after image admission may leave unreferenced content-addressed objects for deferred attachment garbage collection. + +## Testing + +Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md new file mode 100644 index 0000000000..46c62750be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 通用文件上传 + +Status: implemented + +[English](2026-08-26-generic-file-upload.md) | 中文 + +## 问题 + +会话可以把粘贴或拖入的图片作为持久附件接收,但其他任何文件都进不了对话:composer 拒绝非图片字节,唯一与文件相关的入口是 `@` 路径引用,而它要求文件本来就能从宿主工作区读到。用户无法从浏览器把 PDF、数据文件或压缩包交给智能体,而 DeepSeek Chat 用户期待回形针加文件卡的上传交互(#2984)。 + +命令平面已经为图片建立了完整提交规则:命令必须同时消费文本与附件,或明确拒绝整次提交。`input.images` 声明和只支持图片的 RPC 载荷使通用文件在普通消息可用后仍走独立拒绝路径。Plan 和 goal 入口需要使用同一附件词汇,同时保留执行器强制规则,不能静默清除草稿。 + +## 决定 + +文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。普通 Web 页面把每个正在传输的原始 `File` 交给专用上传 Worker,由它发送原始字节并报告字节进度;Worker-hosted 页面把不透明 `Blob` 克隆给已有的 Host Worker,后者以有界分块读取其 stream。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分块写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 + +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Host 会在持久化任何图片前验证所有文件凭证,原 Agent 已销毁时拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 + +## 考虑过的替代方案 + +- **把上传存进项目根目录**(OpenHands / ChatGPT `/mnt/data` 的形态)。拒绝:用户目录里的文件可能被编辑、移动或删除,会打断会话日志的稳定引用,污染 `git status`,还要求工作区必须存在。`DSH_HOME` 用内容寻址保存冻结的只读字节;执行世界可见性由投影路径提供,之后还可以补一步拷贝进工作区。 +- **专用的 `read_file` 工具。** 拒绝:handle 文本指向一条普通路径,现有 `read` 工具(以及模型的其他文件工具)已经覆盖它;再加一个读工具只会分裂模型行为而不增加能力。 +- **把内容急切注入提示词。** 对文件的拒绝理由与当年对 `@` 引用完全相同([web 文件引用](2026-07-27-web-file-and-session-references.zh.md)):附加时相关性未知,惰性工具读取让每次内容访问都留在日志里。 +- **类型白名单与大小上限。** 现阶段拒绝:DeepSeek Chat 的白名单存在是因为其服务端要解析上传,而本 harness 只存字节、让模型自己读。流式载体和存储让内存占用与有界分块大小相关,因此上限属于磁盘与传输策略,不用于保证请求安全。 +- **新增 attachment/file 会话事件。** 拒绝:图片已经采用的「块内联在 `user/message`」模式即可满足模型可见 ⟺ 可日志重建,无需扩大事件词汇。 +- **有附件时阻止所有命令。** 拒绝:`/goal` 目标与 `/plan` 进入需要参考文件,统一拒绝会使这些文件没有进入模型的命令路径。 +- **任何命令成功后自动发送附件。** 拒绝:`/model` 与 `/compact` 等状态命令不表示模型可见输入。附件是否成为用户消息由命令生产方负责。 +- **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。 +- **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。 +- **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。 + +## 后果 + +- 上传字节永不删除且没有大小上限,`DSH_HOME` 的增长无界;垃圾回收与图片保留问题一同继续挂起。 +- 投影路径只在对应执行环境中有效:无法映射 `DSH_HOME` 的沙箱或远程文件系统会得到显式的无路径 handle,模型会报告无法读取该文件。基于存储的读取兜底被推迟。 +- 暂存上传按 Session 存在 Host 内存里;上传与发送之间 Host 重启会出现 `FILE_NOT_STAGED`,客户端重新添加文件即可。 +- 浏览器到 Host 的传输使用一次不带断点续传偏移的流式 HTTP 请求;连接或 Host 失败后的重试会从第一个字节重新传输整个文件。 +- 命令必须声明接受附件。命令拒绝时保留文本草稿与全部附件卡,供用户重试。 +- 子代理不独立上传文件。主 agent 委派时传递保存路径;只有同机或继承父级历史且执行环境能映射该路径的子级可以读取。 +- 上传的文件尚未进入 `@` 补全;该联动是计划中的后续阶段。 +- 命令注册表准入带类型标记的 `CommandSubmitAttachment` 联合类型,其中的文件成员携带暂存凭证,再向处理器提供一个冻结的有序 `ImageBlock | FileBlock` 数组。注册表不安排模型输入;`/goal` 增加一条已记录 follow-up,`/plan` steer 有序附件块。 +- 从菜单选择弹窗命令不会提交 composer 的整套内容。附件保持可见,直到用户提交命令行或移除附件。 +- 声明、存储缺失、未知凭证与图片限制拒绝发生在处理器运行前。生产方语法拒绝和图片准入后的取消可能留下无引用的内容寻址对象,由后续附件垃圾回收处理。 + +## 测试 + +单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7075380d74..196ef992a0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: f9eb7ae0bc634e655462412e7dd5d341b7a449a2 -config-catalog.zh.md: 827f39d068de067aefa4dfc77fc53c3db74b5336 +config-catalog.md: 616ccc3f5e507b1f87cffb3b47bc5fec98e37ae8 +config-catalog.zh.md: 3f8b2a296313d0af307cd0b616560be9c1b1f44b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9eb7ae0bc..616ccc3f5e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -215,7 +215,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) @@ -264,7 +264,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:64`](../packages/attachment/attachment-local/src/index.ts) @@ -338,7 +338,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 827f39d068..3f8b2a2963 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -266,7 +266,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:64`](../packages/attachment/attachment-local/src/index.ts) @@ -340,7 +340,7 @@ export interface ConnectionConfig { } ``` -来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 2ba3190cff..eac9436907 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: f9606dcf399519f0c62b6403f300b66e80e47d36 -event-producer-consumer.zh.md: f9e4d35e6ed213046957b5e7fc1e03a334de9992 +event-producer-consumer.md: 9ea2ce1829e1730e32a5d7a13c48b74ba7b27400 +event-producer-consumer.zh.md: 931cf48cd039bc5daadda847ca62bed8244fd00d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f9606dcf39..9ea2ce1829 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,14 +21,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:547`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:527`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:554`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:533`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:540`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:86`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f9e4d35e6e..931cf48cd0 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:547`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:527`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:554`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:533`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:540`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -45,7 +45,7 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3b1f274aee..5c93025ec2 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: a028d1efd12924f0592b1dba954071cebd9d3dbe -module-graph.zh.md: c4efd116e5c79aa9c0210d5ff3692d7a39cd1c46 +module-graph.md: b389883073e1a23ece82dcba9b8beab741ed5609 +module-graph.zh.md: 13331c88d41faa1b6f3a87becb84eceda39b5ee3 diff --git a/docs/module-graph.md b/docs/module-graph.md index a028d1efd1..b389883073 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1042,6 +1042,7 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1376,7 +1377,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c4efd116e5..13331c88d4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1044,6 +1044,7 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1378,7 +1379,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index c5520d4dd2..a6d5cddd11 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: fd5b3a4a377069fb7b1fbc37b78d11ec037073a4 -persistence-catalog.zh.md: 9d8fc88a44da1620e2de9aa066b9d6c6204b29b1 +persistence-catalog.md: 210f254131b486aca737feabb2d1134b139d0999 +persistence-catalog.zh.md: 62b1e282b0c326bb119e4528cff58391c4167dd5 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index fd5b3a4a37..210f254131 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -260,7 +260,7 @@ Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:109`](../packages/interaction/commands/src/types.ts) @@ -280,7 +280,7 @@ Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:102`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 9d8fc88a44..62b1e282b0 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -262,7 +262,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:109`](../packages/interaction/commands/src/types.ts) @@ -282,7 +282,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:102`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 8e69eaae28..dd8f010d89 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 -attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a +attachment.md: 8c9199bc246fa8866cb5a2bba0edcd187bf779ac +attachment.zh.md: d651d095e1157601086a94f7f4c2116c3bb5dee3 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 15daa2b8d5..8c9199bc24 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -184,6 +184,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +saveFileStream(input: SaveFileStreamAttachment): Promise + +/** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ +async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable + +/** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +fileHostPath(ref: FileAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index c74a18f6be..d651d095e1 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -184,6 +184,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +saveFileStream(input: SaveFileStreamAttachment): Promise + +/** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ +async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable + +/** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +fileHostPath(ref: FileAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 17bf825901..9af51d5fb1 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -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 docs/subsystems/commands.md -commands.md: f3760558b04253701915a240375f13e096bc0c51 -commands.zh.md: 2b4377b76b55203d84a956f66879ab2c50a9fd23 +commands.md: 4077ffff5f5e2d2a71bce46a10229779b3865fac +commands.zh.md: feb5cd7136b5e19cc5c08a7072905db21c1ae95b diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index f3760558b0..4077ffff5f 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -8,7 +8,7 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio ## Input metadata -The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. +The service exposes one optional unstructured-input descriptor: a hint plus an attachment-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ @@ -16,13 +16,13 @@ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } ``` @@ -64,13 +64,13 @@ interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist */ register(definition: CommandDefinition): () => void +/** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ +registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) +Types: [Agent](core.md) Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 2b4377b76b..feb5cd7136 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -8,7 +8,7 @@ ## 输入元数据 -该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 +该服务公开一个可选的非结构化输入描述符:提示文本加附件接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ @@ -16,13 +16,13 @@ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } ``` @@ -64,13 +64,13 @@ interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist */ register(definition: CommandDefinition): () => void +/** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ +registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.zh.md) · [EncodedImageAttachment](attachment.zh.md) +Types: [Agent](core.zh.md) Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index ea90fbb43d..0616e92b48 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -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 docs/subsystems/llm-streaming.md -llm-streaming.md: 6867ae292d77474bcedc1466ae0ce6b1fc1c92d3 -llm-streaming.zh.md: b75e24f2e9010b4fb08c035f14bc4e91dc3971ef +llm-streaming.md: afd2767713c639dda5ffe13eced1500c2cae503b +llm-streaming.zh.md: e094f3f0c7a0faa1f49783ced6d44f5f51682491 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 6867ae292d..afd2767713 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -23,12 +23,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. @@ -951,6 +952,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy */ imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined +/** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ +fileRequestText(ref: FileAttachmentRef): string + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -1006,6 +1015,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` +Types: [FileAttachmentRef](attachment.md) + Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index b75e24f2e9..e094f3f0c7 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -23,12 +23,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 @@ -957,6 +958,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy */ imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined +/** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ +fileRequestText(ref: FileAttachmentRef): string + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -1012,6 +1021,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` +Types: [FileAttachmentRef](attachment.zh.md) + Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 6998ba0cbe..4a9f3b93f5 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 docs/subsystems/session.md -session.md: fbeb501b9ba6283a41c108fc07fc8787ed9e68a4 -session.zh.md: daf25d74bc5bd039a7d8b1a1882c2ec197136ce3 +session.md: 4fe1f3cf186f9c8969039598f236124a8ff3207e +session.zh.md: 7e7075c1e83da5c673c13fc8ed46f9c6875890e9 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index fbeb501b9b..4fe1f3cf18 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -712,6 +712,15 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise +/** + * Persist one encoded file upload verbatim and stage it for a later prompt + * on the same Session. + * @param request - Session identity, base64 payload, and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns an opaque per-upload receipt and the durable file reference. + */ +@Remote('uploadFile') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise + /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index daf25d74bc..7e7075c1e8 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -716,6 +716,15 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise +/** + * Persist one encoded file upload verbatim and stage it for a later prompt + * on the same Session. + * @param request - Session identity, base64 payload, and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns an opaque per-upload receipt and the durable file reference. + */ +@Remote('uploadFile') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise + /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 9e96e16e09..3fe713de29 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: b3d22a340fff6a49270de520a043a1c8dfdbf096 -README.zh.md: 45590a53db17b32cc59f6107957c284b53ee8302 +README.md: 84f819fc1fda4bb657a588a060c2db4298411bae +README.zh.md: 46cc3cc92e2b6615ba4d8bb4e951247984598bd7 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index b3d22a340f..84f819fc1f 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and the adapter for Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, Agent-scoped file references, and generic file upload. Use it through API Gateway when a Client needs operations addressed by a Session. ## Table of Contents @@ -25,11 +25,11 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, file-reference, and file-upload operations may resolve or resume an ordinary Session. A generated Remote accepts base64 file bytes for non-browser carriers, while an authenticated exact Fetch route streams raw chunks from the browser background-upload carrier without entering Connection's buffered JSON path. The streaming path hashes and writes each chunk with backpressure, publishes the completed object atomically, and removes its staging file on cancellation or failure. Both paths store the file verbatim and stage it in this process for the same exact Agent; completion after that Agent is disposed stores no receipt. A later prompt or attachment-accepting command cites the opaque per-upload receipt. Both admission paths validate every same-Session receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message; prompt receipts retire when that `rpcId` becomes a user event or its queued occurrence is removed, while command-only receipts remain until Session disposal. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -60,6 +60,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. +- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 45590a53db..46cc3cc92e 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现,以及面向 Agent 的文件引用 adapter。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现、面向 Agent 的文件引用和通用文件上传。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、文件引用和文件上传操作可以解析或恢复普通 Session。生成 Remote 为非浏览器载体接收 base64 文件字节,经过认证的精确 Fetch 路由则从浏览器后台上传载体流式接收原始分块,不进入 Connection 的缓冲 JSON 路径。流式路径以背压逐块计算摘要和写入,完整接收后以原子方式发布对象,并在取消或失败时移除暂存文件。两条路径都按字节原样存储文件,并在本进程内按同一个准确 Agent 暂存;如果保存完成时该 Agent 已销毁,就不发布凭证。之后的 prompt 或接受附件的命令引用每次上传独有的不透明凭证。两条准入路径都先验证所有凭证属于同一 Session,再持久化图片。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息;prompt 凭证在同一 `rpcId` 进入 user event 或对应 queue occurrence 被移除后退休,只被命令使用的凭证则保留到 Session 销毁。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -60,6 +60,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 +- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a019d28997..9c5f3a1abb 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: 5e9bda9aa13939c3945d005ea474c3caf8daa661 -README.zh.md: 88dcbff120c4c61ea776e43fc906923f039ee09b +README.md: 364153b7b56daa725003178b6cfad90e3f94bc04 +README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 5e9bda9aa1..364153b7b5 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately. It is what the shipped `dsh` composition uses, so durable image attachments work without configuration. Identical normalized images are stored only once, concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine — other hosts cannot read these images — and objects are never deleted automatically. +This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically. ## Table of Contents @@ -25,7 +25,7 @@ This package provides the local storage and image-processing backend for attachm ## Use this package -In the default composition, attach images to a prompt or command and they are stored on this machine automatically. If you compose your own setup, mounting this one plugin gives you durable image attachments. +In the default composition, images and generic files attached to prompts or commands are stored on this machine automatically. If you compose your own setup, mounting this plugin provides durable attachments. ### Minimal configuration @@ -87,12 +87,15 @@ Admission accepts up to 20 images and 200 MiB of source bytes per message; one s Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. + ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `LocalAttachmentStore`, `Config` schema, defaults | | [`src/store.ts`](src/store.ts) | Content-addressed write and verified read: staging, hard-link publish, fsync chain, digest verification | +| [`src/file-store.ts`](src/file-store.ts) | Verbatim streamed file writes, verified streamed reads, and safe stored filenames | | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates | | [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight | | [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification | @@ -117,7 +120,7 @@ For the full service contract and payload types, read the subsystem reference; f ## Model Experience -Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. +Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. Generic files project as text handles naming their identity and read-only process path; when no mapping exists, the handle states that the execution environment cannot read the file. #### KV Cache effect diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 88dcbff120..6ca5c6df82 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存。随附的 `dsh` 组合使用的就是它,因此持久图片附件无需配置即可工作。相同规范化图片只存一份,同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机——其他主机无法读取这些图片——对象也永远不会自动删除。 +本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在默认组合中,把图片附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这一个插件即可获得持久图片附件。 +在默认组合中,把图片或通用文件附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这个插件即可获得持久附件。 ### 最小配置 @@ -87,12 +87,15 @@ kind: "package-reference" 请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 + ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`LocalAttachmentStore`、`Config` schema、默认值 | | [`src/store.ts`](src/store.ts) | 内容寻址写入与校验读取:暂存、硬链接发布、fsync 链、摘要校验 | +| [`src/file-store.ts`](src/file-store.ts) | 原样文件的流式写入、校验式流式读取与安全存储文件名 | | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 | | [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight | | [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 | @@ -117,7 +120,7 @@ kind: "package-reference" ## 模型体验 -本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。 +本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。通用文件会投影为指出文件身份与只读进程路径的文本句柄;无法映射时,句柄会说明当前执行环境不能读取该文件。 #### KV Cache 影响 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 83ba7dbc72..c57edf82cf 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: 9561ab4265eb0404f6f60c93c34dab73eed5ffe1 -README.zh.md: 31fcb15c04fadb44c44342e3d1d721d22645732f +README.md: f815cfa7ea070f28efa3d5f049962b8837072b5d +README.zh.md: eb9275ef63f2d581a0c0b867e1b2e40992391797 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 9561ab4265..f815cfa7ea 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -1,5 +1,5 @@ --- -description: "Durable image attachments for users and maintainers attaching, reusing, or debugging images in prompts and commands." +description: "Durable image and file attachments for users and maintainers attaching, reusing, or debugging uploads in prompts and commands." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -You can attach images to prompts and commands, and the harness keeps provider-independent normalized versions durably: each source image is admitted and normalized before your message is processed, reappears in conversation history, and is projected to the selected model route in later turns of the same session. The shipped `dsh` composition enables this with no setup. Attached images survive restarts, while browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Only raster formats (PNG, JPEG, WebP, GIF) are accepted, and unsent composer drafts stay in the browser until you submit. Stored images are never deleted automatically, and non-image files, audio, and video are not supported yet. +You can attach images and generic files to prompts, and the harness keeps them durably: each source image is admitted and normalized before your message is processed, while any other file is stored byte-for-byte with no format or size limits, and both reappear in conversation history across restarts of the same session. The shipped `dsh` composition enables this with no setup. Browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Images accept raster formats (PNG, JPEG, WebP, GIF) under deployment limits; files accept anything, and the model reads a stored file on demand from its saved read-only path instead of receiving its bytes. Stored objects are never deleted automatically, and audio and video have no dedicated handling yet. ## Table of Contents @@ -35,9 +35,13 @@ Attach one or more images to a user prompt in the client UI. Each source is chec - name: '@deepseek-ai/dsh-attachment-local' ``` -### Pass images to commands +### Attach any other file to a prompt -Commands that accept image input receive attached images the same way. If a command does not accept images, the harness refuses with an error message instead of silently dropping them. +Any non-image file attaches to a prompt as a generic file: the exact bytes are saved read-only under the harness home, the message records the file name, byte size, and content digest, and the model receives one line naming the saved path so it can read the content with its file tools only when needed. There is no file-type whitelist and no size limit; what you attach is stored verbatim. + +### Pass attachments to commands + +Commands declaring attachment input receive images and generic files in selection order. Commands that do not accept attachments return an error and retain the composer's draft and cards. ### Reuse images across the session @@ -64,10 +68,11 @@ This section explains the design decisions behind the seam and the service opera - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers route on `code`, never on the prototype chain. +- **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map @@ -75,7 +80,7 @@ The service family runs one admission-and-storage flow: every entry point enforc |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | -| [`src/admission.ts`](src/admission.ts) | Browser prompt admission: canonical-base64 enforcement, `saveImages` delegation, and durable prompt-part projection | +| [`src/admission.ts`](src/admission.ts) | Browser prompt and file admission: canonical-base64 enforcement, store delegation, and durable image-part projection | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | — | No runtime invariant companion is published; this stateless seam owns types while implementations enforce immutable-store checks. | @@ -98,7 +103,7 @@ For the full service contract and payload types, read the subsystem reference; f ## Model Experience -Indirectly, through the provider adapter, which resolves each durable reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. +Indirectly, through the provider adapter, which resolves each durable image reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. A generic file never reaches the provider as bytes: every route receives one deterministic handle line naming the file, its byte size, its digest prefix, and the saved read-only path to read with file tools. #### KV Cache effect @@ -111,8 +116,8 @@ Adding an image changes the provider request and therefore invalidates the affec These limits describe what image attachments can and cannot do; they are current package constraints, not a task backlog. -- **Raster images only** — PNG, JPEG, WebP, and GIF are accepted; generic files, audio, and video are not supported yet. -- **Images are never deleted** — stored images are retained indefinitely; nothing removes them automatically. +- **Raster image limits apply to images only** — PNG, JPEG, WebP, and GIF are accepted as images under deployment limits; every other file is stored verbatim with no type or size limit, and audio and video have no dedicated handling yet. +- **Attachments are never deleted** — stored images and files are retained indefinitely; nothing removes them automatically. - **Unsent drafts are not saved** — a composer draft stays in the browser until you submit the message. @@ -127,8 +132,8 @@ This Dev Note is working context for maintainers: undecided directions and open Resumed and forked sessions may share immutable objects, so any retention policy needs a reference model that accounts for session lineage before objects can be collected. No decision is recorded yet; the local backend currently retains everything. -#### Future: non-image attachments and assistant-side output +#### Future: audio, video, and assistant-side output -Generic files, audio, and video would need separate lifecycle and provider contracts, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. +Audio and video would need dedicated lifecycle and provider contracts beyond the verbatim file path, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 31fcb15c04..eb9275ef63 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -1,5 +1,5 @@ --- -description: "持久图片附件,供用户与维护者在提示词与命令中附加、复用或排查图片。" +description: "持久图片与文件附件,供用户与维护者在提示词与命令中附加、复用或排查上传内容。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -你可以把图片附加到提示词和命令中,harness 会持久保存提供方无关的规范化版本:每张源图都会在你的消息被处理前准入并规范化,重新出现在对话历史中,并在同一会话的后续轮次投影到所选模型路由。随附的 `dsh` 组合无需任何配置即可支持这一点。已附加的图片在重启后依然存在,而浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。只接受光栅格式(PNG、JPEG、WebP、GIF),未发送的输入区草稿在提交前仍留在浏览器中。已存储的图片永远不会被自动删除,通用文件、音频和视频暂不支持。 +你可以把图片和通用文件附加到提示词中,harness 会持久保存它们:每张源图都会在你的消息被处理前准入并规范化,而其他任何文件都按字节原样保存、不设格式与大小限制,两者都会在同一会话重启后重新出现在对话历史中。随附的 `dsh` 组合无需任何配置即可支持这一点。浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。图片接受部署限额内的光栅格式(PNG、JPEG、WebP、GIF);文件接受任何内容,模型不接收文件字节,而是在需要时从保存的只读路径按需读取。已存储对象永远不会被自动删除,音频和视频暂无专门处理。 ## 目录 @@ -35,9 +35,13 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-attachment-local' ``` -### 把图片传给命令 +### 在提示词中附加任意其他文件 -接受图片输入的命令以相同方式接收附加图片。如果某个命令不接受图片,harness 会以错误消息拒绝,而不是静默丢弃。 +任何非图片文件都以通用文件的形式附加到提示词:确切字节被只读保存在 harness 主目录下,消息记录文件名、字节数与内容摘要,模型收到一行指出保存路径的文本,只在需要时用文件工具读取内容。没有文件类型白名单,也没有大小上限;你附加什么就原样存什么。 + +### 把附件传给命令 + +声明接受附件的命令会按选择顺序接收图片与通用文件。不接受附件的命令会返回错误,并保留 composer 的草稿与附件卡。 ### 在整个会话中复用图片 @@ -64,10 +68,11 @@ kind: "package-reference" - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象都会失败关闭。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方按 `code` 路由,绝不依赖原型链。 +- **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装把它对每条路由都投影成确定性 handle 文本。 ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 @@ -75,7 +80,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | -| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 准入:强制规范 base64、委托 `saveImages` 并投影持久 prompt part | +| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 与文件准入:强制规范 base64、委托存储并投影持久图片 part | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 | @@ -98,7 +103,7 @@ kind: "package-reference" ## 模型体验 -该包通过提供方适配器间接影响模型;适配器会把每个持久引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。 +该包通过提供方适配器间接影响模型;适配器会把每个持久图片引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。通用文件的字节永远不会到达提供方:每条路由都收到一行确定性 handle 文本,指出文件名、字节数、摘要前缀,以及供文件工具读取的只读保存路径。 #### KV Cache 影响 @@ -109,10 +114,10 @@ kind: "package-reference" -这些限制描述了图片附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 +这些限制描述了附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 -- **仅支持光栅图片**——接受 PNG、JPEG、WebP 与 GIF;通用文件、音频和视频暂不支持。 -- **图片永远不会被删除**——已存储的图片无限期保留;没有任何机制自动移除它们。 +- **光栅图片限制只作用于图片**——图片接受部署限额内的 PNG、JPEG、WebP 与 GIF;其他任何文件都原样保存、不设类型与大小限制,音频和视频暂无专门处理。 +- **附件永远不会被删除**——已存储的图片与文件无限期保留;没有任何机制自动移除它们。 - **未发送的草稿不会保存**——输入区草稿在提交消息前一直留在浏览器中。 @@ -127,8 +132,8 @@ kind: "package-reference" 恢复和 fork 后的会话可能共享不可变对象,因此任何保留策略都需要一个能考虑会话血缘的引用模型,之后才能回收对象。目前尚未记录任何决定;本地后端当前保留一切。 -#### 未来:非图片附件与助手侧输出 +#### 未来:音频、视频与助手侧输出 -通用文件、音频与视频需要单独的生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 +音频与视频需要原样文件路径之外的专门生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 4ca433fc99..516d4750e2 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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: 54acac0ed815ea24a4a30a80d0b13eb6ceda6b47 -README.zh.md: 46c7d0b20e3b7a27db963866949d01f1ee96781a +README.md: e4bd8631508a31f8f586eb1dac47ade329a68622 +README.zh.md: 3fe76a8710870dbabbb39be611a0dc9239671ed3 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 54acac0ed8..e4bd863150 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. +The package carries browser-to-Host Remote calls, exact Fetch responses, background Blob uploads, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC and background-upload carriers, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. ## Table of Contents @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD` route registry. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. `connection.backgroundUploads` sends a Blob through a dedicated Worker on served pages and through the existing Host Worker on worker-hosted pages, so the page thread does not materialize large request bytes. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. ----- @@ -58,7 +58,7 @@ None; this package neither assembles nor sends a provider request. -- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. +- **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota. - **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit. - **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 46c7d0b20e..3fe76a8710 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应、后台 Blob 上传与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC 与后台上传 carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD` 路由注册表。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用。`connection.backgroundUploads` 在普通服务页面中通过专用 Worker 发送 Blob,在 Worker-hosted 页面中通过已有 Host Worker 发送,因此页面线程不会物化大请求的字节。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 ----- @@ -58,7 +58,7 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation -- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 +- **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。 - **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。 - **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。 diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 8079b51518..e3571a4a90 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 3fce1c01b6acaa204c92ff240122233c8eb8c388 -README.zh.md: 65f43dd73412703c7cad0174f0f93b1e6eba0c1c +README.md: b36e5b6847d2b8671306510e6dc7a4ed9d4ca189 +README.zh.md: 5d456e153a378eb0664fbb4978322866cbfdf8fe diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3fce1c01b6..b36e5b6847 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -1,5 +1,5 @@ --- -description: "Attachment presentation for the conversation UI: draft-image rail, document drop target, history-image gallery, and original-image lightbox; for users and maintainers of the Web attachment experience." +description: "Attachment presentation for the conversation UI: mixed draft-attachment rail, document drop target, history-image gallery, and original-image lightbox; for users and maintainers of the Web attachment experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat and Trajectory, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. +This package renders everything the conversation UI shows about attachments: one ordered draft rail under the composer, a full-viewport drop invitation, durable images in Chat and Trajectory, and a lightbox for the original image. It is a pure presentation layer — attachment data, upload state, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style attachment experience. ## Table of Contents @@ -25,15 +25,15 @@ This package renders everything the conversation UI shows about attachments: pen ## Use this package -Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, and the Escape/mask/close lightbox. +Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, and the Escape/mask/close lightbox. -### Draft images +### Draft attachments -A draft image shows as a fixed 64px thumbnail in one horizontally scrolling row; edge arrows page the rail when overflow hides items, and the scrollbar stays hidden. A newly added item is revealed at the rail's end, removal keeps the scroll position, and a single click opens the original through the owner's `onOpen`. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original. ### Message images and the lightbox -A message's lone image renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); images among several render as fixed 64px squares. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. +In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. ### Drop overlay @@ -51,8 +51,8 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | File | Role | |---|---| -| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | -| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | +| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Ordered image/file rail + drop overlay assembly | +| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Horizontal attachment overflow, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | @@ -89,7 +89,6 @@ None; this package neither assembles nor sends a provider request. These limits define the current attachment surface. They are package constraints, not a general image-viewer comparison or a task backlog. -- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload progress wait until the composer accepts non-image attachments. - **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only. - **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 65f43dd734..5d456e153a 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -1,5 +1,5 @@ --- -description: "对话 UI 的附件呈现:草稿图片栏、文档拖放目标、历史图片画廊与原图灯箱;供 Web 附件体验的用户与维护者阅读。" +description: "对话 UI 的附件呈现:混合草稿附件栏、文档拖放目标、历史图片画廊与原图灯箱;供 Web 附件体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat 与 Trajectory 中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 +本包渲染对话 UI 中与附件相关的一切:composer 下的一条有序草稿附件栏、全视口拖放邀请层、Chat 与 Trajectory 中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、上传状态、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的附件体验时选择它。 ## 目录 @@ -25,15 +25,15 @@ kind: "package-reference" ## 使用本包 -与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片,以及支持 Escape/遮罩/关闭按钮的灯箱。 +与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带上限说明的拖放遮罩、按数量定尺寸的消息图片,以及支持 Escape、遮罩和关闭按钮的灯箱。 -### 草稿图片 +### 草稿附件 -草稿图片以固定 64px 缩略图呈现在一行横向滚动条中;溢出隐藏时由边缘箭头翻页,滚动条保持隐藏。新增条目滚动到栏尾展示,删除保持原位,单击经持有方的 `onOpen` 打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 -一条消息仅有的一张图按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);多图中的一张渲染为固定 64px 方块。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 +Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 ### 拖放遮罩 @@ -51,8 +51,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | -| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | +| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 有序图片/文件栏+拖放遮罩的组装 | +| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 附件横向溢出、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | @@ -89,7 +89,6 @@ kind: "package-reference" 这些限制界定了当前附件表面。它们是包约束,不是通用图片查看器对比或任务积压。 -- **仅支持图片**——非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度等输入框接受非图片附件后再做。 - **灯箱无缩放与下载**——预览仅以适配视口的尺寸渲染原图。 - **灯箱不锁定焦点**——它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面。 diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index be9c1f8b43..e87a9b0483 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md -README.md: b27c795eaf9a7954925961bde81872307b9f3d97 -README.zh.md: 65d31d0f390fce0ea341bf3391903539d3e0d0f7 +README.md: 045364d43e27d8be07ce5847af5d02e461d10aa6 +README.zh.md: aa9c5d8b0729202098f9323892da710f54d0f9a9 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index b27c795eaf..045364d43e 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -31,9 +31,9 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so A contribution is a client-owned command — a host-name collision fails loud. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. -### Image-carrying submissions +### Attachment-carrying submissions -When the composer submits with image attachments, only a host command declaring `input.images` proceeds; every other command route throws the localized `imagesUnsupported` refusal, which renders as a transient toast while the draft and images stay in place — a command can never consume the text and strand the images. +When the composer submits with images or generic files, only a host command declaring `input.attachments` proceeds. Every other command route throws the localized `attachmentsUnsupported` refusal, rendered as a transient toast while the draft and attachment cards stay in place. Handler errors preserve the same draft state for retry. ----- diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 65d31d0f39..aa9c5d8b07 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -31,9 +31,9 @@ kind: "package-reference" 贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 -### 带图提交 +### 带附件提交 -composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续;其余每条命令路径都会抛出本地化的 `imagesUnsupported` 拒绝,以瞬态 toast 呈现,草稿与图片保持原位——命令绝不消费文本却抛下图片。 +composer 携带图片或通用文件提交时,只有声明了 `input.attachments` 的宿主命令继续。其余命令路径都会抛出本地化的 `attachmentsUnsupported` 拒绝,以瞬态 toast 呈现,草稿与附件卡保持原位。处理器返回错误时保留相同草稿状态供用户重试。 ----- diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 54c5167b50..2a4c1c1cb1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3292c39477a051ac406f453e781376164333ee78 -README.zh.md: 433b2e6c8b3580dadf5dd9e38eaea7b1ab7022a6 +README.md: 5310c8491481949f919c9b61c56a1f11202150a5 +README.zh.md: f0bbe2e822d3d01b359bd47e2512c12d9d85bf28 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3292c39477..5310c84914 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -42,11 +42,11 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). +While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Plan mode and active goals do not change attachment intake. Continuable subagents keep separate Send and Stop actions but expose no paperclip, paste, or drop intake ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 433b2e6c8b..f0bbe2e822 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -42,11 +42,11 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 +普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。Plan Mode 与 active goal 不改变附件入口。continuable 子代理保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 ## 临时 composer entry diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index a1d5f1c686..ca6481dbca 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 02daa3a7e4440b4ea4901f98139044bbda68827f -README.zh.md: 0a1aa49a94d68cc1ab56e02831e4cbf06be5fdb1 +README.md: f2b04037414646a94e793daabca5c2afdab1bc26 +README.zh.md: db67df4d8293efac7d54cfd8ae3d7c6d0fb107a4 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 02daa3a7e4..f2b0403741 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-trajectory` is the Trajectory view of the dsh web client: it renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records, plus an interactive timing overview. Thick rules mark Turn boundaries, compact inline markers identify Steps, and selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images from user, assistant, or tool content. The view is a pure consumer: it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `conversation.view` slot ring, and provides no service and declares no Context merge. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. Long ledgers open at the current tail, page older history on demand, and mount only the visible row window. +`dsh-client-ui-trajectory` is the Trajectory view of the dsh web client: it renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records, plus an interactive timing overview. Thick rules mark Turn boundaries, compact inline markers identify Steps, and selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, durable images, and file-attachment summaries from user, assistant, or tool content. The view is a pure consumer: it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `conversation.view` slot ring, and provides no service and declares no Context merge. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. Long ledgers open at the current tail, page older history on demand, and mount only the visible row window. ## Table of Contents @@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records -Selection, timeline navigation, folding, search, and Request totals cover the loaded window. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A record without text labels its row with the image count. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, search, and Request totals cover the loaded window. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 0a1aa49a94..db67df4d82 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-trajectory` 是 dsh Web 客户端的 Trajectory 视图:它渲染按轮次组织的事件记录表,其中可选择用户、助手、工具与嵌套子工具记录,并带交互式时间概览。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤;选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时,以及用户、助手或工具内容中的持久图片。该视图是纯消费方:它注册 target 专属 Event Definition、Trajectory view builder 以及对话 `conversation.view` slot 环中的一个视图标签页,不提供 service,也不声明 Context 合并。带类型的 `trajectory` locale namespace 拥有所有产品编写的 ledger、timeline、inspector、tooltip 与无障碍文案;事件内容、工具名称、标识符与 provider 诊断保持原始数据。长记录表打开时定位于当前尾部、按需加载更早历史,并且只挂载可见行窗口。 +`dsh-client-ui-trajectory` 是 dsh Web 客户端的 Trajectory 视图:它渲染按轮次组织的事件记录表,其中可选择用户、助手、工具与嵌套子工具记录,并带交互式时间概览。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤;选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时,以及用户、助手或工具内容中的持久图片与文件附件摘要。该视图是纯消费方:它注册 target 专属 Event Definition、Trajectory view builder 以及对话 `conversation.view` slot 环中的一个视图标签页,不提供 service,也不声明 Context 合并。带类型的 `trajectory` locale namespace 拥有所有产品编写的 ledger、timeline、inspector、tooltip 与无障碍文案;事件内容、工具名称、标识符与 provider 诊断保持原始数据。长记录表打开时定位于当前尾部、按需加载更早历史,并且只挂载可见行窗口。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查记录 -选择、时间线导航、折叠、搜索与请求汇总只覆盖已加载的窗口。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠、搜索与请求汇总只覆盖已加载的窗口。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 ### 时间概览 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 74f6ba36d3..036fa37bd8 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -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/experimental/webworker-runtime/README.md -README.md: 87d53937bee73d7e771ad957ea6c5d953965169f -README.zh.md: d11c074ca02bab12a9f72be17b035389507e05cb +README.md: bb561f2e9ec02d0a322e9d89919fc9f8515fb64a +README.zh.md: c90c476c95275220d5f801006b9d557f461d0bef diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 87d53937be..bb561f2e9e 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -28,7 +28,7 @@ Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. Request frames preserve Blob bodies through structured clone; the Host Worker streams their chunks into the route, so neither browser thread creates a complete byte array for a generic-file upload. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index d11c074ca0..c90c476c95 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。请求帧通过结构化克隆保留 Blob body,Host Worker 将其逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 82e85ba55b..2b7e6db63f 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/README.i18n.yaml @@ -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/goal/command-goal/README.md -README.md: e602035323eae61df74df06569b934a641140ec0 -README.zh.md: 10438236c40693f97433ce554ad061df5bf47ea3 +README.md: d8f49882d404bfe68e203c7445001cb648d9282a +README.zh.md: 00db532e97a39175579c5299c7ea598b07be0440 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index e602035323..d8f49882d4 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-command-goal` gives the human `/goal` command over the persisted goal service: a user can create, edit, pause, resume, clear, and inspect the current goal directly from the UI, without involving the model. The command registers in its Cordis scope, so command adapters reading that scope discover and execute it, while command text and output stay in the UI — they never enter model requests. Every accepted mutation persists through the goal service's durable `goal/change` event. Image attachments may accompany a create or edit and are submitted as one ordinary user message so later goal rounds see them. Choose it for interactive deployments with a command adapter; headless and automation apps without one do not need it. +`dsh-command-goal` gives the human `/goal` command over the persisted goal service: a user can create, edit, pause, resume, clear, and inspect the current goal directly from the UI, without involving the model. The command registers in its Cordis scope, so command adapters reading that scope discover and execute it, while command text and output stay in the UI — they never enter model requests. Every accepted mutation persists through the goal service's durable `goal/change` event. Ordered image and file attachments may accompany a create or edit and are submitted as one ordinary user message so later goal rounds see them. Choose it for interactive deployments with a command adapter; headless and automation apps without one do not need it. ## Table of Contents @@ -44,9 +44,9 @@ Every sub-command runs against the current goal of the invoking agent; a bare `/ Control words (`clear`, `pause`, `resume`, `edit`) are recognized only when they occupy the complete input; any other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. `edit` takes its replacement inline and refuses to replace an unfinished goal directly. Expected domain rejections become stable, direct command errors without exposing branded ids or revisions; unexpected implementation failures still fail dispatch so adapters can report them as command failures. -### Image attachments +### Attachments -`/goal` declares image support, so a composer may attach images to an invocation. Attachments only accompany an objective: on a successful create or edit the command submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`, so later goal rounds read them from ordinary session history without the goal domain storing attachment state. Every other sub-command, and any refused create or edit, returns a direct error and submits nothing, so the dispatching composer keeps the images. +`/goal` declares attachment support. Attachments accompany only an objective: after a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Later goal rounds read that ordinary session history; the goal domain stores no attachment state. Every other sub-command, and any refused create or edit, returns a direct error before a domain mutation and leaves the dispatching composer's draft and cards intact. ### Compose it @@ -77,7 +77,7 @@ This section explains how the command parses input and renders output; the obser - **Grammar, not free text.** The parser recognizes only the exact control words (`clear`, `pause`, `resume`, `edit`) when they fill the whole input; every other non-empty suffix is an objective. `edit` alone is invalid, and `edit` refuses to replace an unfinished goal directly. - **Domain rejections become stable errors.** `GoalError` outcomes are converted into direct command errors with a fixed message; unexpected failures rethrow so adapters report a command failure rather than a domain result. Rendered output never exposes branded ids or revisions. -- **Attachments ride the objective.** On a successful create or edit the command submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`; every other path submits nothing, so the dispatching composer keeps the images. +- **Attachments accompany the objective.** On a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Every other path submits nothing, so the dispatching composer keeps the draft and cards. ### Source map @@ -108,11 +108,11 @@ The command is a thin adapter over the goal domain; read these pages for the sta #### What the model sees -The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries image attachments, the model sees one ordinary user message: the image blocks followed by the text `Reference images for the goal objective.`; it precedes the next goal round in session history. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries attachments, the model sees one ordinary user message: the ordered image and file blocks followed by the text `Reference attachments for the goal objective.` It precedes the next goal round in session history. #### Token effect -Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's image attachments add one user message billed like any image prompt. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's attachments add one ordinary user message with the normal text, image, and file-handle costs. #### KV Cache effect diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index 10438236c4..00db532e97 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-command-goal` 为用户提供基于持久 goal 服务的 `/goal` 命令:用户可以直接在 UI 中创建、编辑、暂停、恢复、清除并查看当前 goal,无需模型参与。命令在其 Cordis scope 中注册,因此读取该 scope 的命令适配器能发现并执行它;命令文本与输出都留在 UI 中——绝不进入模型请求。每项被接受的变更都会通过 goal 服务的持久 `goal/change` 事件落盘。图片附件可以随 create 或 edit 一起提交,并以一条普通用户消息发出,供后续 Goal Round 读取。为挂载了命令适配器的交互式部署选择它;没有适配器的无头与自动化应用不需要它。 +`dsh-command-goal` 为用户提供基于持久 goal 服务的 `/goal` 命令:用户可以直接在 UI 中创建、编辑、暂停、恢复、清除并查看当前 goal,无需模型参与。命令在其 Cordis scope 中注册,因此读取该 scope 的命令适配器能发现并执行它;命令文本与输出都留在 UI 中,绝不进入模型请求。每项被接受的变更都会通过 goal 服务的持久 `goal/change` 事件落盘。有序的图片与文件附件可以随 create 或 edit 一起提交,并以一条普通用户消息发出,供后续 Goal Round 读取。为挂载了命令适配器的交互式部署选择它;没有适配器的无头与自动化应用不需要它。 ## 目录 @@ -44,9 +44,9 @@ kind: "package-reference" 只有控制词(`clear`、`pause`、`resume`、`edit`)占据完整输入时才被识别;其他任何非空后缀都是目标,因此 `/goal pause after verification` 会创建该字面目标。`edit` 内联接收替换内容,并拒绝直接替换未完成的 goal。可预期的领域拒绝会变成稳定的直接命令错误,不暴露带品牌类型的 id 或 revision;意外实现失败仍会让分发失败,使适配器能将其报告为命令失败。 -### 图片附件 +### 附件 -`/goal` 声明了图片支持,因此 composer 可以随调用附加图片。附件只随目标本身:create 或 edit 成功时,命令提交一条用户 followup 消息,携带已准入的图片块加固定文本 `Reference images for the goal objective.`,后续 Goal Round 从普通会话历史读取它们,goal 领域不存储附件状态。其他任何子命令、以及被拒绝的 create 或 edit,都直接返回错误且不提交任何消息,分发方 composer 保留图片。 +`/goal` 声明附件支持。附件只随目标本身:create 或 edit 成功后,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。后续 Goal Round 从普通会话历史读取这些内容,goal 领域不存储附件状态。其他任何子命令以及被拒绝的 create 或 edit,都会在领域变更前返回直接错误,并保留 composer 的草稿和附件卡。 ### 组合方式 @@ -77,7 +77,7 @@ kind: "package-reference" - **语法,而非自由文本。** 解析器只在控制词(`clear`、`pause`、`resume`、`edit`)填满整个输入时识别它们;其他任何非空后缀都是目标。单独的 `edit` 无效,且 `edit` 拒绝直接替换未完成的 goal。 - **领域拒绝变成稳定错误。** `GoalError` 结果会转换为带固定消息的直接命令错误;意外失败会重新抛出,使适配器报告命令失败而非领域结果。渲染输出绝不暴露带品牌类型的 id 或 revision。 -- **附件随目标而行。** create 或 edit 成功时,命令提交一条用户 followup 消息,携带已准入的图片块加固定文本 `Reference images for the goal objective.`;其他路径都不提交任何消息,因此分发方 composer 保留图片。 +- **附件随目标提交。** create 或 edit 成功时,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。其他路径不提交消息,因此分发方 composer 保留草稿和附件卡。 ### 源码地图 @@ -108,11 +108,11 @@ kind: "package-reference" #### 模型看到的内容 -斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续续行提示词中暴露结果状态。呈现文本绝不会记录到日志中。当 create 或 edit 携带图片附件时,模型会看到一条普通用户消息:图片块后跟文本 `Reference images for the goal objective.`,在会话历史中位于下一个 Goal Round 之前。 +斜杠输入、变更以及直接状态或错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续续行提示词中暴露结果状态。呈现文本不会记录到日志中。当 create 或 edit 携带附件时,模型会看到一条普通用户消息:有序的图片块与文件块后跟文本 `Reference attachments for the goal objective.`。它在会话历史中位于下一个 Goal Round 之前。 #### Token 影响 -读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的图片附件会增加一条用户消息,其计费与任何图片提示词相同。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的附件会增加一条普通用户消息,产生常规文本、图片和文件句柄成本。 #### KV Cache 影响 diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index c5737c521f..90922a259a 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/README.i18n.yaml @@ -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/interaction/commands/README.md -README.md: 4f7a1f4475bf7edeb8e3bae6eec8b8a04b411c3d -README.zh.md: af9a23adf6a88a0b046760ece6e3cf1cdaac1fd6 +README.md: cad2e7c3fc9a17a21679376b1d1a921efb72918a +README.zh.md: abb538d6ad4988e3ad105b822e19365340fdfd1c diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index 4f7a1f4475..cad2e7c3fc 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and image-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. +`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and attachment-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. ## Table of Contents @@ -53,13 +53,13 @@ A command line starts with a slash at byte zero, a lowercase name containing let A plain registration is global. A command-producing plugin mounted beneath an agent's own context declares its `commands` injection and registers an exact agent-scoped command, which shadows the global definition of the same name for that agent only. -### Image attachments +### Attachments -A command may declare `input.images` to accept composer image attachments. The executor enforces the declaration: images sent to a non-declaring command, an absent attachment store, or an over-limit batch each settle as an error before the handler runs. Admitted images reach the handler as frozen ordered `ImageBlock`s on `invocation.attachments`, and the handler owns their model-visible use — the registry never schedules them itself. +A command may declare `input.attachments` to accept composer images and generic files. The executor enforces the declaration: attachments sent to a non-declaring command, an absent attachment store, an unknown Session-scoped file-upload receipt, or an over-limit image batch each settle as an error before the handler runs. Images cross the command wire as base64 input; generic files cite receipts from their completed background uploads, so command submission never reads their bytes again. Admitted `ImageBlock`s and `FileBlock`s reach the handler as one frozen `invocation.attachments` array in the user's selection order, and the handler owns their model-visible use. ### Dispatching from an adapter -An interactive adapter calls `execute(agent, line, images, signal)` with the exact receiving agent, the full command line, and the submission's images. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. +An interactive adapter calls `execute(agent, line, attachments, signal)` with the exact receiving agent, the full command line, and the submission's ordered attachments. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. ### Cancellation @@ -92,9 +92,9 @@ The observable behavior is covered in [Use this package](#use-this-package); thi Registrations live in global and agent-scoped layers merged per agent via `ScopedLayers`. The child-injection shape — a command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection — preserves agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the mutation or starve later observers. -### Image admission +### Attachment admission -Image enforcement happens in the executor, not the composer: an admitted batch is committed through `admitEncodedImages` against the `attachments` store, a rejected batch publishes no durable object, and cancellation is honored before the handler runs so a retrying caller never duplicates state. Handlers that cannot use the images return an error, so the dispatching composer keeps the originals. +Attachment enforcement happens in the executor: images are committed through `admitEncodedImages`, files are resolved through the single Session-aware receipt provider, and the executor restores their original mixed order before calling the handler. Validation rejection starts no attachment writes. An image-storage failure can leave unreachable content-addressed objects for deferred collection, but publishes no model-visible message. Cancellation is honored before the handler runs. Command errors leave the dispatching composer's draft and attachment cards intact. @@ -119,7 +119,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. Image attachments follow the same rule: the executor only admits them into durable attachment objects, and a declaring producer decides whether and how they become model-visible message content. +The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message and ordered attachments in `/plan [message]` after selecting plan mode. The executor only admits attachments into durable objects; the declaring producer decides whether and how they become model-visible message content. #### Token effect diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index af9a23adf6..abb538d6ad 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与图片接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 +`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与附件接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 ## 目录 @@ -53,13 +53,13 @@ ctx.commands.register({ 普通注册全局生效。挂载在 agent 自身上下文之下的命令生产插件会声明 `commands` 注入,并注册精确限定到该 agent 的命令;该定义只对这个 agent 遮蔽同名的全局定义。 -### 图片附件 +### 附件 -命令可以声明 `input.images` 以接受 composer 图片附件。执行器负责声明的强制执行:把图片发给未声明的命令、附件存储缺失或批量超出限制,都会在处理器运行前以错误结果结算。通过准入的图片以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器,其模型可见用途由处理器负责——注册表本身绝不定时调度它们。 +命令可以声明 `input.attachments` 以接受 composer 图片与通用文件。执行器负责强制执行声明:把附件发给未声明的命令、附件存储缺失、Session 范围内的文件上传凭证未知或图片批量超出限制,都会在处理器运行前以错误结果结算。图片以 base64 输入通过命令 wire,通用文件则引用后台上传完成后得到的凭证,因此命令提交不会再次读取文件字节。通过准入的 `ImageBlock` 与 `FileBlock` 按用户选择顺序组成冻结的 `invocation.attachments` 数组,其模型可见用途由处理器负责。 ### 从适配器分派 -交互式适配器调用 `execute(agent, line, images, signal)`,传入确切的接收 agent、完整命令行与本次提交的图片。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 +交互式适配器调用 `execute(agent, line, attachments, signal)`,传入确切的接收 agent、完整命令行与本次提交的有序附件。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 ### 取消 @@ -92,9 +92,9 @@ ctx.commands.register({ 注册表通过 `ScopedLayers` 维护全局层与按 agent 的作用域层,并按 agent 合并视图。子级注入形态——挂载在 `agent.ctx` 之下的命令生产插件声明自身的 `commands` 注入——保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层内的名称重复会在注册时失败;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -### 图片准入 +### 附件准入 -图片强制执行发生在执行器而非 composer 中:通过准入的批量经 `admitEncodedImages` 提交给 `attachments` 存储,被拒绝的批量不发布任何持久化对象;取消会在处理器运行前被处理,因此重试的调用方绝不会重复状态。无法使用图片的处理器会返回错误,使分发方 composer 保留原件。 +附件强制执行发生在执行器中:图片经 `admitEncodedImages` 提交,文件通过唯一的 Session 感知凭证提供方解析,执行器在调用处理器前恢复原始混合顺序。验证拒绝不会开始写入附件。图片存储失败可能留下等待清理且无法引用的内容寻址对象,但不会发布模型可见消息。取消会在处理器运行前生效。命令返回错误时,分发方 composer 的草稿与附件卡保持原位。 @@ -119,7 +119,7 @@ ctx.commands.register({ #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息与有序附件。执行器只负责把附件准入为持久化对象,是否以及如何成为模型可见消息由声明接受的生产方决定。 #### Token 影响 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index e5a5b22f6a..7d7bbf1baa 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 6baa020a9503f32a71231d30e78ec7a3d8862f2b -README.zh.md: f985736da44a76b063f16915a53cb19979fe1962 +README.md: 53810380b341c3e7e2ec91972e94bb4629b65ecb +README.zh.md: a8058c11b36243e408de3cbdd8a0c3f69389e8e6 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 6baa020a95..53810380b3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -99,7 +99,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants @@ -107,6 +107,7 @@ A request is validated against its exact model's capability — context window, - **Replay state travels only within one adapter** — assistant replay state rides along only when the same adapter instance owns the historical and target routes; otherwise it is dropped before dispatch. - **Prepared calls are one-shot** — a prepared call can be dispatched exactly once, and its call-config fields must match the prepared config. - **Image projection follows the captured route** — durable `ImageBlock` references become route-specific request versions only for image-capable models; text-only models receive stable placeholders. +- **File projection is unconditional** — no provider receives file bytes; every route gets one deterministic handle line per `FileBlock`, and the model reads the saved copy with its file tools on demand. - **Protocol ordering** — `usage` precedes `finish`, tool arguments stay raw JSON strings, and nothing follows the terminal `finish`. - **Registry mutations are atomic** — route and directory registration validates the whole candidate set before anything moves, so a refused change leaves the previous state serving. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index f985736da4..a8058c11b3 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -99,7 +99,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 @@ -107,6 +107,7 @@ for await (const chunk of ctx.llm.stream({ - **回放状态只在同一适配器内流动**——仅当同一适配器实例同时拥有历史路由与目标路由时,assistant 回放状态才会随行;否则在分发前被丢弃。 - **已准备调用是一次性的**——已准备调用只能分发一次,且其调用配置字段必须与准备好的配置一致。 - **图片投影遵循捕获的路由**——只有支持图片的模型会把持久 `ImageBlock` 引用转换为路由专用请求版本;纯文本模型接收稳定占位符。 +- **文件投影无条件进行**——没有任何提供方会收到文件字节;每条路由对每个 `FileBlock` 都得到一行确定性 handle 文本,模型在需要时用文件工具读取保存的副本。 - **协议顺序**——`usage` 先于 `finish`,工具参数保持原始 JSON 字符串,终止 `finish` 之后不再有任何内容。 - **注册表变更具有原子性**——路由与目录注册会在任何变动前整体校验候选集合,因此被拒绝的变更会让此前状态继续服务。 diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index bb885f0bfc..6413c0ba99 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: f2a4f7f7c70de4a08fd824e6e8001622ddb456b5 -README.zh.md: 3b163198279d4b254cd78638ebcad479d063630f +README.md: e23274f99adccf64d9ea832def9ad88682dbde1d +README.zh.md: cdb5f6728ec364524cadc9dfa6b78f3e9bd2d01d diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index f2a4f7f7c7..e23274f99a 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-token-meter` is the replay-aware token measurement service: `ctx.tokenMeter` advances one isolated fold per session from the durable event log, so compaction and other pressure-sensitive plugins share one accounting without depending on the compaction engine. With it you can measure current request and context pressure, price a single message, and — when the session-projection seam is mounted — read the `tokenUsage`, `contextPressure`, and `contextBreakdown` projections. It uses a fixed heuristic for text and routes without image pricing, applies adapter-declared visual-token pricing when available, and reuses provider-reported usage only when the request envelope matches exactly. It adds no prompt, message, schema, or tool of its own, and it never makes decisions for the loop. +`@deepseek-ai/dsh-token-meter` is the replay-aware token measurement service: `ctx.tokenMeter` advances one isolated fold per session from the durable event log, so compaction and other pressure-sensitive plugins share one accounting without depending on the compaction engine. With it you can measure current request and context pressure, price a single message, and read the `tokenUsage`, `contextPressure`, and `contextBreakdown` projections when the session-projection seam is mounted. It uses a fixed heuristic for text and routes without image pricing, applies adapter-declared visual-token pricing when available, prices files as the handle text request assembly sends, and reuses provider-reported usage only when the request envelope matches exactly. It adds no prompt, message, schema, or tool of its own, and it never makes decisions for the loop. ## Table of Contents @@ -40,13 +40,13 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. +Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. ### Session projections When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. -`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The route-priced `measure().surfaceTokens` diverges when the routed model reprices images. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens`, which describe the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The request-priced `measure().surfaceTokens` diverges when the routed model reprices images or request assembly projects files to handle text. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error in the composition rows, including the severe CJK text and JSON schema underpricing from the four-characters-per-token rule. Present them as an approximate composition, never as a total. `deriveTurnTokenUsage(events)` folds one complete Turn into exact per-attempt and total usage for browser consumers. Missing lifecycle evidence, unsafe counts, or contradictory exact totals return no result; optional cache, reasoning, and route aggregates appear only when every contributing attempt reports them. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 3b16319827..cdb5f6728e 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-token-meter` 是具备回放感知的 token 计量服务:`ctx.tokenMeter` 从持久事件日志为每个会话推进一个隔离 fold,因此压缩(compaction)与其他压力敏感插件可以共享同一份计量,无需依赖压缩引擎。借助它,你可以测量当前请求与上下文压力、为单条消息计价,并且在挂载会话投影 seam 时读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 投影。文本和未声明图片定价的路由使用固定启发式规则,存在时则应用适配器声明的视觉 token 定价;只有请求 envelope 完全匹配时才复用提供方报告的用量。它不添加任何自己的提示词、消息、schema 或工具,也绝不为 loop 做决定。 +`@deepseek-ai/dsh-token-meter` 是具备回放感知的 token 计量服务:`ctx.tokenMeter` 从持久事件日志为每个会话推进一个隔离 fold,因此压缩(compaction)与其他压力敏感插件可以共享同一份计量,无需依赖压缩引擎。借助它,你可以测量当前请求与上下文压力、为单条消息计价,并在挂载会话投影 seam 时读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 投影。文本和未声明图片定价的路由使用固定启发式规则,存在时应用适配器声明的视觉 token 定价,文件则按请求组装实际发送的 handle 文本计价;只有请求 envelope 完全匹配时才复用提供方报告的用量。它不添加任何自己的提示词、消息、schema 或工具,也绝不为 loop 做决定。 ## 目录 @@ -40,13 +40,13 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 +每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 handle 文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 ### 会话投影 当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 -`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。路由定价的 `measure().surfaceTokens` 在路由模型重新为图片计价时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点体现了这些明细行仍然带有的误差(按「4 字符 ≈ 1 token」计价时,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。请求定价的 `measure().surfaceTokens` 在路由模型重新为图片计价或请求组装把文件投影成 handle 文本时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点包含组成数据仍有的误差,其中按「4 字符约等于 1 token」计价会严重低估 CJK 文本与 JSON schema。请把它们当作近似的**组成**呈现,不要当作总量。 `deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整 Turn 折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。 diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 9dd9a5e938..788b81ba04 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -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/plan/plan-mode/README.md -README.md: e768a19c67b2973ff32aa0fe29e51d1b90ea96c9 -README.zh.md: 9d2b1ed6e1eebee6f3e9cd27fcc8ebad2c626a89 +README.md: b9a86fe530923956365b17dbe5e0ececa28b3416 +README.zh.md: 5c1e21bac520dcd973c181d70139969abcff8b1b diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index e768a19c67..b9a86fe530 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-plan-mode` adds plan mode to the agent: while it is active, the agent explores and designs before executing, guided by instructions the deployment writes, and presents the finished plan for your approval before carrying it out. You enter plan mode with `/plan` (optionally with a message or images) and leave it with `/plan off`; the finished plan arrives as a review where you can approve it or send the agent back to keep planning. Plan mode is guidance, not enforcement: every tool stays available, so sandbox mode and approval prompts remain the way to impose limits. Choose it when the agent should think before acting, and plan mode carries over when a session resumes or forks. +`dsh-plan-mode` adds plan mode to the agent: while it is active, the agent explores and designs before executing, guided by instructions the deployment writes, and presents the finished plan for your approval before carrying it out. You enter plan mode with `/plan`, optionally carrying a message and ordered image or file attachments, and leave it with `/plan off`; the finished plan arrives as a review where you can approve it or send the agent back to keep planning. Plan mode is guidance, not enforcement: every tool stays available, so sandbox mode and approval prompts remain the way to impose limits. Choose it when the agent should think before acting, and plan mode carries over when a session resumes or forks. ## Table of Contents @@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Type `/plan` to enter plan mode, or `/plan ` to enter with an instruction — the message becomes your next request under plan guidance. Type `/plan off` to leave plan mode directly; it also cancels a plan-mode entry that has not taken effect yet. -You can attach images to a `/plan` message, and they are included with your instruction. `/plan off` with images is rejected so the images are not lost. The `/plan` command is available wherever slash commands are supported, such as the Web client. +You can attach images and generic files to a `/plan` message, and they are included with your instruction in selection order. `/plan off` with attachments is rejected before the mode changes, so the draft and cards remain available. The `/plan` command is available wherever slash commands are supported, such as the Web client. ### The reviewed exit @@ -86,7 +86,7 @@ The package persists one log-only whole-value event, `plan/mode`, and the last l ### The `/plan` command -The command child activates only when a commands service is composed. It maps bare `/plan` to active, the exact argument `off` to inactive without model input, and any other non-empty argument to active plus the trimmed text submitted through `agent.steer()` as the next step's ordinary logged user message; image attachments ride the steered message, and `/plan off` with images fails before any mode change. Entry points other than the command may drive `ctx.planMode` directly; the exact branch handling is in [`src/index.ts`](src/index.ts). +The command child activates only when a commands service is composed. It maps bare `/plan` to active, the exact argument `off` to inactive without model input, and any other non-empty argument to active plus the trimmed text submitted through `agent.steer()` as the next step's ordinary logged user message. Admitted image and file blocks keep their selection order in that message; `/plan off` with attachments fails before any mode change. Entry points other than the command may drive `ctx.planMode` directly; the exact branch handling is in [`src/index.ts`](src/index.ts). ### The exit tool @@ -149,11 +149,11 @@ The section is stable within plan mode, but entering or leaving changes the syst #### What the model sees -`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. +`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: admitted image and file blocks in selection order, then the trimmed text block. Bare `/plan` with admitted attachments steers one user message containing only those blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. #### Token effect -The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice. +The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without attachments and `/plan off` add none; bare `/plan` with attachments has the normal image and file-handle cost. A narrated active exit adds the small retained switch notice. #### KV Cache effect diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 9d2b1ed6e1..5c1e21bac5 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-plan-mode` 为 agent(智能体)提供计划模式:激活期间,agent 先探索和设计再执行,遵循你的部署所写的引导行事,并在执行前把完成的计划呈交你批准。你可以用 `/plan`(可附带消息或图片)进入计划模式,用 `/plan off` 离开;完成的计划会以评审形式呈现,你可以批准它,或让 agent 回去继续规划。计划模式是引导而非强制:每个工具仍然可用,因此沙箱模式与审批提示仍是施加限制的方式。当希望 agent 先思考再行动时选择它;会话恢复或 fork 后计划模式依然保持。 +`dsh-plan-mode` 为 agent(智能体)提供计划模式:激活期间,agent 先探索和设计再执行,遵循你的部署所写的引导行事,并在执行前把完成的计划呈交你批准。你可以用 `/plan` 进入计划模式,并随命令附带消息及有序的图片或文件附件;用 `/plan off` 离开。完成的计划会以评审形式呈现,你可以批准它,或让 agent 回去继续规划。计划模式是引导而非强制:每个工具仍然可用,因此沙箱模式与审批提示仍是施加限制的方式。当希望 agent 先思考再行动时选择它;会话恢复或 fork 后计划模式依然保持。 ## 目录 @@ -54,7 +54,7 @@ kind: "package-reference" 输入 `/plan` 进入计划模式,或输入 `/plan ` 连同一条指令一起进入——该消息会成为你在计划引导下的下一条请求。输入 `/plan off` 直接离开计划模式;它还会取消尚未生效的计划模式进入。 -你可以在 `/plan` 消息中附带图片,图片会随你的指令一起提交。带图片的 `/plan off` 会被拒绝,因此图片不会丢失。`/plan` 命令在支持斜杠命令的界面中可用,例如 Web 客户端。 +你可以在 `/plan` 消息中附带图片与通用文件,它们按选择顺序随指令一起提交。带附件的 `/plan off` 会在模式变更前被拒绝,因此草稿和附件卡仍可继续使用。`/plan` 命令在支持斜杠命令的界面中可用,例如 Web 客户端。 ### 经评审的退出 @@ -86,7 +86,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### `/plan` 命令 -命令子插件只在组合了命令服务时激活。它把不带参数的 `/plan` 映射为激活,把恰好为 `off` 的参数映射为未激活且不发送模型输入,把其他非空参数映射为激活并把去除首尾空白的文本通过 `agent.steer()` 作为下一步骤的普通已记录用户消息提交;图片附件随被 steer 的消息一起提交,带图片的 `/plan off` 会在任何模式变更前失败。命令以外的入口可以直接驱动 `ctx.planMode`;确切的分支处理见 [`src/index.ts`](src/index.ts)。 +命令子插件只在组合了命令服务时激活。它把不带参数的 `/plan` 映射为激活,把恰好为 `off` 的参数映射为未激活且不发送模型输入,把其他非空参数映射为激活并把去除首尾空白的文本通过 `agent.steer()` 作为下一步骤的普通已记录用户消息提交。已准入的图片块与文件块按选择顺序进入这条消息;带附件的 `/plan off` 会在模式变更前失败。命令以外的入口可以直接驱动 `ctx.planMode`;确切的分支处理见 [`src/index.ts`](src/index.ts)。 ### 退出工具 @@ -149,11 +149,11 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型看到什么 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择计划模式后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入图片,会 steer 一条只含这些图片块的用户消息。计划模式已激活时选择 `/plan off`,只会在最后记录的请求头描述了计划模式的情况下追加标准的已记录用户切换通知;取消待生效条目不贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择计划模式后,通过 `agent.steer()` 成为一条用户消息:已准入的图片块与文件块按选择顺序排列,之后是去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入附件,会 steer 一条只含这些块的用户消息。计划模式已激活时选择 `/plan off`,只会在最后记录的请求头描述了计划模式的情况下追加标准的已记录用户切换通知;取消待生效条目不贡献通知,因为没有请求观测到它。 #### Token 影响 -可选消息的历史 token 成本与单独提交该内容相同。不带图片的 `/plan` 与 `/plan off` 不增加 token;带图片的 `/plan` 产生常规图片提示词成本。一次带叙述的激活状态退出会追加一条简短且会保留的切换通知。 +可选消息的历史 token 成本与单独提交该内容相同。不带附件的 `/plan` 与 `/plan off` 不增加 token;带附件的 `/plan` 产生常规图片与文件句柄成本。一次带叙述的激活状态退出会追加一条简短且会保留的切换通知。 #### KV Cache 影响 diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 2fcd2c63d0..4f9961dd14 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -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/session-query/session-log-export/README.md -README.md: a096cea71b2e0bed4882a6e8d23c8f35dc3d0fb7 -README.zh.md: 0c2da35ae0be9420cfd6ff076dd038c3a8edf2cb +README.md: 8f3b7b7b1fb459b991067de0de0535a86171ee6a +README.zh.md: cb408aac608171ce97fe174a22be60abf7b1653c diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index a096cea71b..8f3b7b7b1f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -55,7 +55,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u ### What to expect -The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. +The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. JSONL artifacts retain their original names, images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. ### Failures diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 0c2da35ae0..cb408aac60 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -55,7 +55,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 预期行为 -弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。 +弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。JSONL 产物保留原文件名,图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大文件时不会把它完整缓冲进内存。 ### 失败 From 2217ba42d73fd3abb0908aa6e4d7fec283f1fb73 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 2 Sep 2026 11:01:45 +0800 Subject: [PATCH 07/37] fix(ui): align attachment cards with theme constraints --- packages/client/ui-attachment/src/FileCard.module.css | 4 +++- .../ui-attachment/src/client/ComposerAttachments.module.css | 3 ++- .../client/ui-chat/src/client/chat/MessageItem.module.css | 2 +- .../ui-conversation/src/client/queue/QueueDock.module.css | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css index 3925088b66..71de9ec4bf 100644 --- a/packages/client/ui-attachment/src/FileCard.module.css +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -6,7 +6,7 @@ width: 240px; height: 64px; padding: 0 12px; - border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); border-radius: 16px; background: var(--dsw-specific-input-major, transparent); box-sizing: border-box; @@ -32,6 +32,7 @@ border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; + corner-shape: round; animation: file-card-spin 0.8s linear infinite; } @@ -92,6 +93,7 @@ padding: 0; border: none; border-radius: 50%; + corner-shape: round; background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.72)); color: var(--dsw-alias-label-primary-inverted, #fff); opacity: 0; diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 8fcb9f1134..b4a8eba014 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -14,7 +14,7 @@ height: 64px; padding: 0; overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); border-radius: 16px; background: var(--dsw-alias-interactive-bg-hover); cursor: zoom-in; @@ -39,6 +39,7 @@ padding: 0; border: none; border-radius: 50%; + corner-shape: round; background: var(--dsw-alias-button-contrast-fill); color: var(--dsw-alias-label-primary-inverted); cursor: pointer; diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index fd90d1ebec..2f44408b33 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -309,7 +309,7 @@ width: 240px; min-height: 64px; padding: 8px 12px; - border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); border-radius: 16px; background: var(--dsw-specific-input-major, transparent); box-sizing: border-box; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 4e7182770d..a328956ba3 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -140,7 +140,7 @@ height: 24px; padding: 0 6px; overflow: hidden; - border: 1px solid var(--dsw-alias-border-l1); + border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; background: var(--dsw-alias-bg-base); box-sizing: border-box; From bbb2ca7c9b3ee5e088c895351f5916e00cc8a05f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 13:53:25 +0800 Subject: [PATCH 08/37] refactor(client): extract background file upload service --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 6 +- .../2026-08-26-generic-file-upload.zh.md | 6 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/package.json | 4 +- .../src/client/contract/session.ts | 6 +- .../src/client/contract/snapshot.ts | 16 +- .../session-controller/src/client/index.ts | 9 +- .../src/client/sessions/manager.ts | 3 - .../src/client/sessions/service.ts | 3 - .../src/client/sessions/session.ts | 21 +- ...session-pending-submissions.client.spec.ts | 18 +- .../tests/session.client.spec.ts | 82 ++++- .../session-controller/tsconfig.client.json | 1 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- .../src/client/background-upload.ts | 184 ---------- .../client/connection/src/client/index.ts | 18 +- .../tests/background-upload.client.spec.ts | 193 ----------- .../client/connection/tsconfig.client.json | 1 - packages/client/file-upload/README.i18n.yaml | 6 + packages/client/file-upload/README.md | 94 +++++ packages/client/file-upload/README.zh.md | 94 +++++ packages/client/file-upload/package.json | 51 +++ .../client/file-upload/src/client/contract.ts | 49 +++ .../client/file-upload/src/client/index.ts | 32 ++ .../client/file-upload/src/client/runtime.ts | 248 ++++++++++++++ packages/client/file-upload/src/index.ts | 4 + .../tests/file-upload.client.spec.ts | 322 ++++++++++++++++++ packages/client/file-upload/tsconfig.json | 15 + packages/client/file-upload/tsdown.config.ts | 3 + .../ui-chat/src/client/chat/MessageItem.tsx | 10 +- .../ui-chat/tests/chat-view.client.spec.tsx | 16 +- .../src/client/queue/QueueDock.tsx | 10 +- .../ui-conversation/src/client/service.ts | 12 +- .../tests/queue-dock.client.spec.tsx | 7 +- .../service-orchestration.client.spec.ts | 11 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../webworker-runtime/package.json | 1 + .../webworker-runtime/src/client/client.ts | 15 +- .../webworker-runtime/src/client/index.ts | 9 + .../webworker-runtime/src/transport/frames.ts | 11 +- .../src/transport/synthetic-http.ts | 10 + .../tests/transport/frames.spec.ts | 10 +- .../tests/transport/tunnel-client.spec.ts | 18 +- .../tests/transport/tunnel-server.spec.ts | 30 ++ .../webworker-runtime/tsconfig.json | 3 + pnpm-lock.yaml | 15 + scripts/gen-cordis-catalog.ts | 1 + tsconfig.base.json | 2 + tsconfig.client.json | 1 + 61 files changed, 1225 insertions(+), 500 deletions(-) delete mode 100644 packages/client/connection/src/client/background-upload.ts delete mode 100644 packages/client/connection/tests/background-upload.client.spec.ts create mode 100644 packages/client/file-upload/README.i18n.yaml create mode 100644 packages/client/file-upload/README.md create mode 100644 packages/client/file-upload/README.zh.md create mode 100644 packages/client/file-upload/package.json create mode 100644 packages/client/file-upload/src/client/contract.ts create mode 100644 packages/client/file-upload/src/client/index.ts create mode 100644 packages/client/file-upload/src/client/runtime.ts create mode 100644 packages/client/file-upload/src/index.ts create mode 100644 packages/client/file-upload/tests/file-upload.client.spec.ts create mode 100644 packages/client/file-upload/tsconfig.json create mode 100644 packages/client/file-upload/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index c41fb5319b..39f6330118 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: 91f331cfc43cdfd771953b27baffb712eb4cbd19 -2026-08-26-generic-file-upload.zh.md: 46c62750be7331acc384cb8c5271ef49cedb73f4 +2026-08-26-generic-file-upload.md: bc8eb9783096e5e7763969630b57419a64c0cb9f +2026-08-26-generic-file-upload.zh.md: dbc392d700a84a77d321e5637be34da8c113a331 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index 91f331cfc4..bc8eb97830 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -12,7 +12,7 @@ The command plane had already established a whole-envelope rule for images: a co ## Decision -Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. Served Web pages hand each active `File` to a dedicated upload Worker, which sends raw bytes and reports byte progress; Worker-hosted pages clone the opaque `Blob` to the existing Host Worker, which reads its stream in bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. +Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent Client `fileUpload` Cordis service accepts `Blob` and one-shot `ReadableStream` bodies; a Session resolves it from its Agent scope instead of carrying an upload object through `ClientSessions`, `SessionManager`, and constructor options. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. The Host validates every file receipt before persisting any image, rejects upload completion after the original Agent is disposed, and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. @@ -28,6 +28,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- - **Store objective attachments in the goal domain.** Rejected: one ordinary logged user message gives later goal rounds the same history without widening the goal schema or duplicating attachments into round prompts. - **Consume attachments on any command success.** Rejected: `/goal pause` or `/plan off` would clear unrelated cards. Consumption follows the producer's explicit success; grammar forms that cannot use attachments return an error. - **Enforce declarations only in the client.** Rejected: direct RPC callers can bypass the composer. `CommandRuntime` enforces declaration, store availability, same-Session file-receipt resolution, image base64 admission, and image limits before entering the handler. +- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. An independent Cordis service is inherited by the Session's existing Agent scope and lets browser and Worker-hosted pages replace the carrier without widening Connection. ## Consequences @@ -35,6 +36,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- - The projected path is execution-environment-local: a sandboxed or remote filesystem that cannot map `DSH_HOME` yields an explicit no-path handle, and the model reports that it cannot read the file. A store-backed read fallback is deferred. - Staged uploads live in Host memory per Session; a Host restart between upload and send surfaces `FILE_NOT_STAGED` and the client re-adds the file. - The browser-to-Host transfer is one streaming HTTP request without resumable offsets; retrying after connection or Host failure retransmits the file from byte zero. +- A `ReadableStream` upload consumes and transfers the stream once; retrying requires a newly created stream, and progress cannot report a total unless the producer supplies length through a future API. - Commands must declare attachment acceptance. Rejected commands preserve the text draft and every attachment card for retry. - Subagents do not upload files independently. A main agent delegates with the saved path; only same-machine or inherited-history children whose execution environment maps that path can read it. - Uploaded files do not appear in `@` completion yet; that integration is a planned later phase. @@ -44,4 +46,4 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- ## Testing -Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. +Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 46c62750be..dbc392d700 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。普通 Web 页面把每个正在传输的原始 `File` 交给专用上传 Worker,由它发送原始字节并报告字节进度;Worker-hosted 页面把不透明 `Blob` 克隆给已有的 Host Worker,后者以有界分块读取其 stream。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分块写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 +文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的 Client `fileUpload` Cordis 服务接受 `Blob` 和只能使用一次的 `ReadableStream` 请求体;Session 从自己的 Agent scope 取得该服务,无需让 `ClientSessions`、`SessionManager` 和构造参数逐层携带上传对象。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Host 会在持久化任何图片前验证所有文件凭证,原 Agent 已销毁时拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 @@ -28,6 +28,7 @@ Status: implemented - **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。 - **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。 - **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。 +- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立 Cordis 服务由 Session 现有的 Agent scope 继承,并允许普通浏览器与 Worker-hosted 页面替换载体,而无需扩大 Connection。 ## 后果 @@ -35,6 +36,7 @@ Status: implemented - 投影路径只在对应执行环境中有效:无法映射 `DSH_HOME` 的沙箱或远程文件系统会得到显式的无路径 handle,模型会报告无法读取该文件。基于存储的读取兜底被推迟。 - 暂存上传按 Session 存在 Host 内存里;上传与发送之间 Host 重启会出现 `FILE_NOT_STAGED`,客户端重新添加文件即可。 - 浏览器到 Host 的传输使用一次不带断点续传偏移的流式 HTTP 请求;连接或 Host 失败后的重试会从第一个字节重新传输整个文件。 +- `ReadableStream` 上传只消费并转移一次 stream;重试必须重新创建 stream,而且在未来 API 允许生产方提供长度前,进度不能报告总量。 - 命令必须声明接受附件。命令拒绝时保留文本草稿与全部附件卡,供用户重试。 - 子代理不独立上传文件。主 agent 委派时传递保存路径;只有同机或继承父级历史且执行环境能映射该路径的子级可以读取。 - 上传的文件尚未进入 `@` 补全;该联动是计划中的后续阶段。 @@ -44,4 +46,4 @@ Status: implemented ## 测试 -单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 +单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 7484cb3724..12587c382c 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 4f5d41ae34690e74a880c57434f99f06a12b23b8 -README.zh.md: 2404da3973e36514fdf27a78055403baffe2a478 +README.md: ab4001052306be1b052d1ca6a0e8ff8ddeffd459 +README.zh.md: 5100959e826e1099007a85d34b345a7d7d8481e8 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4f5d41ae34..ab40010523 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,7 +27,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, file-reference, and file-upload operations may resolve or resume an ordinary Session. A generated Remote accepts base64 file bytes for non-browser carriers, while an authenticated exact Fetch route streams raw chunks from the browser background-upload carrier without entering Connection's buffered JSON path. The streaming path hashes and writes each chunk with backpressure, publishes the completed object atomically, and removes its staging file on cancellation or failure. Both paths store the file verbatim and stage it in this process for the same exact Agent; completion after that Agent is disposed stores no receipt. A later prompt or attachment-accepting command cites the opaque per-upload receipt. Both admission paths validate every same-Session receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message; prompt receipts retire when that `rpcId` becomes a user event or its queued occurrence is removed, while command-only receipts remain until Session disposal. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. A Client Session resolves the independent [`fileUpload`](../../client/file-upload/README.md) service from its Agent scope for `Blob` and one-shot `ReadableStream` bodies. A fixture or bare Session keeps the generated Remote fallback for `Blob` and exact-byte inputs; a stream requires the background service because it cannot be replayed through the base64 Remote. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 2404da3973..5100959e82 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、文件引用和文件上传操作可以解析或恢复普通 Session。生成 Remote 为非浏览器载体接收 base64 文件字节,经过认证的精确 Fetch 路由则从浏览器后台上传载体流式接收原始分块,不进入 Connection 的缓冲 JSON 路径。流式路径以背压逐块计算摘要和写入,完整接收后以原子方式发布对象,并在取消或失败时移除暂存文件。两条路径都按字节原样存储文件,并在本进程内按同一个准确 Agent 暂存;如果保存完成时该 Agent 已销毁,就不发布凭证。之后的 prompt 或接受附件的命令引用每次上传独有的不透明凭证。两条准入路径都先验证所有凭证属于同一 Session,再持久化图片。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息;prompt 凭证在同一 `rpcId` 进入 user event 或对应 queue occurrence 被移除后退休,只被命令使用的凭证则保留到 Session 销毁。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Session 从自己的 Agent scope 取得独立的 [`fileUpload`](../../client/file-upload/README.zh.md) 服务,用于 `Blob` 与只能使用一次的 `ReadableStream` 请求体。fixture 或未绑定的 Session 会让 `Blob` 与精确字节输入继续使用生成的 Remote;stream 无法通过 base64 Remote 重放,因此必须有后台服务。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 717417a170..a1cc71db37 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -47,7 +47,8 @@ "@deepseek-ai/dsh-api-gateway/client" ], "inject": [ - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-file-upload" ], "platform": "web" } @@ -120,6 +121,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index ec85cc42e9..653ed05442 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -92,14 +92,14 @@ export interface ISession { /** * Persist one browser file verbatim and stage it for a later prompt on this * session. The returned opaque receipt is what a prompt file part cites. - * @param data - browser Blob or exact file bytes. + * @param data - browser Blob, exact file bytes, or a one-shot byte stream. * @param name - optional display name; the host sanitizes the stored leaf name. * @param signal - optional cancellation for the active upload. - * @param onProgress - optional byte-progress observer for background Blob uploads. + * @param onProgress - optional byte-progress observer for background Blob or stream uploads. * @returns the staged-upload receipt and durable file reference, or the business error. */ uploadFile( - data: Blob | Uint8Array, + data: Blob | Uint8Array | ReadableStream, name?: string, signal?: AbortSignal, onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 3b4f449d69..aba257f893 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -31,10 +31,22 @@ export interface PendingSubmissionImage { readonly height?: number } +/** Image branch of a local submission echo attachment. */ +export interface PendingSubmissionImageAttachment { + readonly type: 'image' + readonly value: PendingSubmissionImage +} + +/** File branch of a local submission echo attachment. */ +export interface PendingSubmissionFileAttachment { + readonly type: 'file' + readonly value: FileAttachmentRef +} + /** One attachment displayed by a local submission echo, in prompt order. */ export type PendingSubmissionAttachment = - | ({ readonly type: 'image' } & PendingSubmissionImage) - | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | PendingSubmissionImageAttachment + | PendingSubmissionFileAttachment /** Client surface selected when a local submission begins. */ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 7df2341ebb..7d09877d40 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,7 +2,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-client-file-upload/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -61,7 +62,9 @@ export type { OpenState, PendingSubmission, PendingSubmissionAttachment, + PendingSubmissionFileAttachment, PendingSubmissionImage, + PendingSubmissionImageAttachment, PendingSubmissionPlacement, PromptError, QueuedMessage, @@ -78,6 +81,7 @@ declare module '@deepseek-ai/cordis' { /** Required Remote and Context projection services. */ export const inject = [ 'connection', + 'fileUpload', 'typert', 'remote', 'remote.commands', @@ -90,9 +94,8 @@ export const inject = [ * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { - const connection = ctx.get('connection') as ConnectionHandle const remotes = ctx.remote as unknown as SessionRemotes - const sessions = new ClientSessions(ctx, remotes, connection.backgroundUploads) + const sessions = new ClientSessions(ctx, remotes) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index e48e987363..fd39fefedf 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,7 +25,6 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' -import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client' function sessionSeqCursor(value: number): SessionSeqCursor { return value === -1 ? -1 : SessionSeq(value) @@ -154,7 +153,6 @@ export class SessionManager { private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, - private readonly backgroundUploads?: BackgroundUploadTransport, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -334,7 +332,6 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), - ...(this.backgroundUploads === undefined ? {} : { backgroundUploads: this.backgroundUploads }), }) } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 6fe816b221..d1d840c513 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -32,7 +32,6 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../scope.ts' import { SessionManager } from './manager.ts' import type { SessionRemotes } from './remotes.ts' -import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { Session } from './session.ts' @@ -222,7 +221,6 @@ export class ClientSessions implements ISessions { constructor( private readonly rootCtx: Context, remote: SessionRemotes, - backgroundUploads?: BackgroundUploadTransport, ) { this.selection = createSnapshotStore( {}, @@ -232,7 +230,6 @@ export class ClientSessions implements ISessions { remote, restored.sessionId, restored.subagentAddress, - backgroundUploads, ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 74abf9f6f7..cb92cff454 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -2,9 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { - BackgroundUploadProgress, BackgroundUploadTransport, -} from '@deepseek-ai/dsh-client-connection/client' +import type { FileUploadProgress } from '@deepseek-ai/dsh-client-file-upload/client' import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -76,8 +74,6 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore - /** Physical large-body carrier supplied by the active browser Connection. */ - backgroundUploads?: BackgroundUploadTransport } /** @@ -302,15 +298,17 @@ export class Session implements SessionFace { /** * Persist one browser file verbatim and stage it for a later prompt on this * session (ordinary sessions only; subagent conversations refuse). - * @param data - exact file bytes. + * @param data - exact bytes, a browser Blob, or a one-shot byte stream. * @param name - optional display name; the host sanitizes the stored leaf name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. * @returns the staged-upload receipt and durable file reference, or the business error. */ async uploadFile( - data: Blob | Uint8Array, + data: Blob | Uint8Array | ReadableStream, name?: string, signal?: AbortSignal, - onProgress?: (progress: BackgroundUploadProgress) => void, + onProgress?: (progress: FileUploadProgress) => void, ): Promise> { if (this.address !== undefined) { return { @@ -322,10 +320,10 @@ export class Session implements SessionFace { ), } } - if (data instanceof Blob && this.options.backgroundUploads !== undefined) { + if (!(data instanceof Uint8Array) && this.actx?.fileUpload.available === true) { const query = new URLSearchParams({ sessionId: this.sessionId }) if (name !== undefined) query.set('name', name) - const response = await this.options.backgroundUploads.post({ + const response = await this.actx.fileUpload.post({ path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`, body: data, headers: { 'content-type': 'application/octet-stream' }, @@ -337,6 +335,9 @@ export class Session implements SessionFace { } return parseFileUploadResult(response.body) } + if (!(data instanceof Uint8Array) && !(data instanceof Blob)) { + throw new Error('stream file upload requires a bound Client file-upload service') + } const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) return this.remote.session.uploadFile({ sessionId: this.sessionId, diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 7b4f093894..46cfe1c049 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -84,14 +84,18 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '你好', - attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, + }], }) expect(session.getSnapshot().promptAttempted).toBe(true) expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ requestId: handle.requestId, placement: 'transcript', text: '你好', - attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, + }], }]) }) @@ -168,7 +172,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '发送', - attachments: [{ type: 'image', previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] @@ -188,7 +192,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '排队', - attachments: [{ type: 'image', previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] @@ -210,9 +214,9 @@ describe('observed retirement', () => { mode: 'queue', text: 'mixed', attachments: [ - { type: 'image', previewUrl: 'blob:first' }, - { type: 'file', attachment: file }, - { type: 'image', previewUrl: 'blob:last' }, + { type: 'image', value: { previewUrl: 'blob:first' } }, + { type: 'file', value: file }, + { type: 'image', value: { previewUrl: 'blob:last' } }, ], onRetire: retirement => retirements.push(retirement), }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 162e70fc23..341046c80f 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,6 +1,8 @@ /** Session object lifecycle, event-window transport, commands, and resync behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { FileUploadService } from '@deepseek-ai/dsh-client-file-upload/client' import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' @@ -23,6 +25,16 @@ function makeSession( return { api, session: new Session(SID, fakeRemote(api), options) } } +function bindFileUpload( + session: Session, + post: FileUploadService['post'], + available = true, +): void { + const ctx = new Context() + ctx.reflect.provide('fileUpload', { available, post }) + session.bindScope(ctx) +} + function follow( api: FakeApiClient, event: SessionEvent, @@ -50,7 +62,7 @@ describe('Session file upload', () => { const progress = vi.fn() const post = vi.fn(async (request: { path: string - body: Blob + body: Blob | ReadableStream headers?: Readonly> signal?: AbortSignal onProgress?: (progress: { loaded: number; total?: number }) => void @@ -67,7 +79,8 @@ describe('Session file upload', () => { }), } }) - const { api, session } = makeSession(undefined, { backgroundUploads: { post } }) + const { api, session } = makeSession() + bindFileUpload(session, post) const abort = new AbortController() const file = new Blob([Uint8Array.of(1, 2, 3, 4)]) @@ -96,7 +109,8 @@ describe('Session file upload', () => { error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, }), })) - const { session } = makeSession(undefined, { backgroundUploads: { post } }) + const { session } = makeSession() + bindFileUpload(session, post) await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({ ok: false, error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, @@ -119,6 +133,56 @@ describe('Session file upload', () => { ]) }) + it('keeps fixture Blob fallback on the Remote and refuses an uncarried stream', async () => { + const { api, session } = makeSession() + const post = vi.fn() + bindFileUpload(session, post, false) + + await expect(session.uploadFile(new Blob([Uint8Array.of(1)]), 'fixture.bin')) + .resolves.toMatchObject({ ok: true }) + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await expect(session.uploadFile(stream, 'stream.bin')) + .rejects.toThrow('stream file upload requires a bound Client file-upload service') + expect(post).not.toHaveBeenCalled() + expect(api.callsOf('session.uploadFile')).toEqual([ + { sessionId: SID, data: 'AQ==', name: 'fixture.bin' }, + ]) + }) + + it('hands a one-shot ReadableStream to the scoped file-upload service', async () => { + const post = vi.fn(() => Promise.resolve({ + status: 200, + body: JSON.stringify({ + ok: true, + value: { + receiptId: 'stream-receipt', + file: { attachmentId: 'stream-file', name: 'stream.bin', bytes: 3 }, + }, + }), + })) + const { session } = makeSession() + bindFileUpload(session, post) + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2, 3)) + controller.close() + }, + }) + await expect(session.uploadFile(stream, 'stream.bin')).resolves.toMatchObject({ ok: true }) + expect(post).toHaveBeenCalledWith(expect.objectContaining({ + path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=stream.bin', + body: stream, + })) + }) + + it('refuses a stream when a bare Session has no scoped upload service', async () => { + const { session } = makeSession() + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await expect(session.uploadFile(stream)).rejects.toThrow( + 'stream file upload requires a bound Client file-upload service', + ) + }) + it('folds non-200 and malformed background responses into transport failures', async () => { const bodies: unknown[] = [ null, @@ -137,15 +201,13 @@ describe('Session file upload', () => { { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, ] for (const body of bodies) { - const { session } = makeSession(undefined, { - backgroundUploads: { post: () => Promise.resolve({ status: 200, body: JSON.stringify(body) }) }, - }) + const { session } = makeSession() + bindFileUpload(session, () => Promise.resolve({ status: 200, body: JSON.stringify(body) })) await expect(session.uploadFile(new Blob([]))) .rejects.toThrow(/file upload transport returned an invalid/) } - const { session } = makeSession(undefined, { - backgroundUploads: { post: () => Promise.resolve({ status: 503, body: 'unavailable' }) }, - }) + const { session } = makeSession() + bindFileUpload(session, () => Promise.resolve({ status: 503, body: 'unavailable' })) await expect(session.uploadFile(new Blob([]))) .rejects.toThrow('file upload transport failed with HTTP 503') }) @@ -155,8 +217,8 @@ describe('Session file upload', () => { const api = new FakeApiClient() const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - backgroundUploads: { post }, }) + bindFileUpload(session, post) await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({ ok: false, error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } }, diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index d88247a535..4331140c7a 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -16,6 +16,7 @@ { "path": "../gateway/tsconfig.client.json" }, { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/file-upload" }, { "path": "../../client/store" }, { "path": "../../context/file-reference" }, { "path": "../../core/session" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 2c9bd25990..ee0217066d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -168,6 +168,11 @@ # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. trustedHosts: !!js ctx.webRuntime.trustedHosts + # Raw Blob and ReadableStream uploads run independently of Connection's + # RPC and generation service. + - id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' + - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 77e803c655..bfac3429ba 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 3f826371d5..e6c2329963 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 2358d94f6db0455aaf8fce094a616a273d4b3bab -README.zh.md: 4fbeacf33dc609102a78418c3679a43f06914bdf +README.md: 27fa0abba847d99dd5553a36e25077e4fac3b56c +README.zh.md: 51a2d40a1ed2ea9a7bfe2ffe9ce96d23a4d27e73 diff --git a/packages/client/README.md b/packages/client/README.md index 2358d94f6d..27fa0abba8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`web/`](web/README.md) | Boots the browser shell | — | | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | +| [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 4fbeacf33d..51a2d40a1e 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ kind: "package-group" | [`web/`](web/README.zh.md) | 启动浏览器外壳 | — | | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | +| [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 516d4750e2..e1e4ff6e1c 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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: e4bd8631508a31f8f586eb1dac47ade329a68622 -README.zh.md: 3fe76a8710870dbabbb39be611a0dc9239671ed3 +README.md: 91e96a34d15463fd4246bf8fd8aee6112aaa54c4 +README.zh.md: fc6686e688170427528cb51db51e166f3c447f26 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index e4bd863150..91e96a34d1 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The package carries browser-to-Host Remote calls, exact Fetch responses, background Blob uploads, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC and background-upload carriers, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. ## Table of Contents @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, backgro ## Use this package -The browser uses HTTP POST for Remote unary calls. `connection.backgroundUploads` sends a Blob through a dedicated Worker on served pages and through the existing Host Worker on worker-hosted pages, so the page thread does not materialize large request bytes. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 3fe76a8710..fc6686e688 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应、后台 Blob 上传与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC 与后台上传 carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用。`connection.backgroundUploads` 在普通服务页面中通过专用 Worker 发送 Blob,在 Worker-hosted 页面中通过已有 Host Worker 发送,因此页面线程不会物化大请求的字节。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- diff --git a/packages/client/connection/src/client/background-upload.ts b/packages/client/connection/src/client/background-upload.ts deleted file mode 100644 index 7d6b87315d..0000000000 --- a/packages/client/connection/src/client/background-upload.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** Background browser upload transport for large opaque request bodies. */ - -import type { RpcFetch } from './rpc.ts' - -/** Monotone byte progress reported by a browser upload carrier. */ -export interface BackgroundUploadProgress { - readonly loaded: number - readonly total?: number -} - -/** One background upload request. */ -export interface BackgroundUploadRequest { - readonly path: string - readonly body: Blob - readonly headers?: Readonly> - readonly signal?: AbortSignal - readonly onProgress?: (progress: BackgroundUploadProgress) => void -} - -/** Small response returned after the background carrier has sent the body. */ -export interface BackgroundUploadResponse { - readonly status: number - readonly body: string -} - -/** Browser carrier that keeps file reads and network submission off the page thread. */ -export interface BackgroundUploadTransport { - /** - * Post one Blob without materializing its bytes on the page thread. - * @param request - target, body, cancellation, and progress observer. - * @returns the response status and text body. - */ - post(request: BackgroundUploadRequest): Promise -} - -interface UploadWorkerStart { - readonly url: string - readonly body: Blob - readonly headers: Readonly> -} - -type UploadWorkerOutput = - | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number } - | { readonly kind: 'complete'; readonly status: number; readonly body: string } - | { readonly kind: 'error'; readonly message: string } - -interface UploadWorkerScope { - onmessage: ((event: MessageEvent) => void) | null - postMessage(message: UploadWorkerOutput): void -} - -interface UploadXhr { - readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } - status: number - responseText: string - withCredentials: boolean - onload: ((event: ProgressEvent) => void) | null - onerror: ((event: ProgressEvent) => void) | null - open(method: string, url: string): void - setRequestHeader(name: string, value: string): void - send(body: Blob): void -} - -/** - * Self-contained Worker body; its string form becomes the Blob Worker source. - * @param scope - Worker global used for request and progress messages. - * @param createXhr - XMLHttpRequest factory; injectable for unit coverage. - */ -export function backgroundUploadWorker( - scope: UploadWorkerScope = self, - createXhr: () => UploadXhr = () => new XMLHttpRequest(), -): void { - scope.onmessage = (event: MessageEvent) => { - const request = event.data - const xhr = createXhr() - xhr.open('POST', request.url) - xhr.withCredentials = true - for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value) - xhr.upload.onprogress = (progress) => { - scope.postMessage({ - kind: 'progress', - loaded: progress.loaded, - ...(progress.lengthComputable ? { total: progress.total } : {}), - } satisfies UploadWorkerOutput) - } - xhr.onload = () => { - scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput) - } - xhr.onerror = () => { - scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput) - } - xhr.send(request.body) - } -} - -/** - * Create a background body carrier. A custom fetch already targets a Host - * Worker, while the served Web path creates a dedicated upload Worker. - * @param customFetch - worker-hosted transport hook, when present. - * @returns the selected background carrier. - */ -export function createBackgroundUploadTransport(customFetch?: RpcFetch): BackgroundUploadTransport { - return customFetch === undefined ? workerTransport() : customTransport(customFetch) -} - -function customTransport(customFetch: RpcFetch): BackgroundUploadTransport { - return { - async post(request) { - const response = await customFetch(resolveUrl(request.path), { - method: 'POST', - ...(request.headers === undefined ? {} : { headers: request.headers }), - body: request.body, - ...(request.signal === undefined ? {} : { signal: request.signal }), - }) - return { status: response.status, body: await response.text() } - }, - } -} - -function workerTransport(): BackgroundUploadTransport { - return { - post(request) { - if (typeof Worker !== 'function') { - return Promise.reject(new Error('background upload requires Web Worker support')) - } - const workerUrl = URL.createObjectURL(new Blob([ - `(${backgroundUploadWorker.toString()})()`, - ], { type: 'text/javascript' })) - const worker = new Worker(workerUrl, { name: 'dsh-file-upload' }) - URL.revokeObjectURL(workerUrl) - return new Promise((resolve, reject) => { - let settled = false - const abort = (): void => { - settled = true - worker.terminate() - request.signal?.removeEventListener('abort', abort) - reject(new DOMException('The operation was aborted.', 'AbortError')) - } - const finish = (settle: () => void): void => { - if (settled) return - settled = true - request.signal?.removeEventListener('abort', abort) - worker.terminate() - settle() - } - worker.onmessage = (event: MessageEvent) => { - const output = event.data - if (output.kind === 'progress') { - request.onProgress?.({ - loaded: output.loaded, - ...(output.total === undefined ? {} : { total: output.total }), - }) - } else if (output.kind === 'complete') { - finish(() => { resolve({ status: output.status, body: output.body }) }) - } else { - finish(() => { reject(new Error(output.message)) }) - } - } - worker.onerror = (event) => { - finish(() => { reject(new Error(event.message || 'background upload worker failed')) }) - } - if (request.signal?.aborted === true) { - abort() - return - } - request.signal?.addEventListener('abort', abort, { once: true }) - worker.postMessage({ - url: resolveUrl(request.path).href, - body: request.body, - headers: request.headers ?? {}, - } satisfies UploadWorkerStart) - }) - }, - } -} - -function resolveUrl(path: string): URL { - const pageLocation = Reflect.get(globalThis, 'location') as unknown - const origin = typeof pageLocation === 'object' && pageLocation !== null - && 'origin' in pageLocation && typeof pageLocation.origin === 'string' - ? pageLocation.origin - : undefined - return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) -} diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b70e40c59e..ad568f55bf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,4 +1,4 @@ -/** Browser wire client: Remote transport, connection generations, and background uploads. */ +/** Browser wire client: Remote transport and connection generations. */ import type { Context } from '@deepseek-ai/cordis' import { ConnectionController, @@ -10,10 +10,6 @@ import { } from './connection.ts' import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' -import { - createBackgroundUploadTransport, - type BackgroundUploadTransport, -} from './background-upload.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -54,12 +50,6 @@ export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' -export type { - BackgroundUploadProgress, - BackgroundUploadRequest, - BackgroundUploadResponse, - BackgroundUploadTransport, -} from './background-upload.ts' /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { @@ -130,8 +120,6 @@ export interface ConnectionHandle { readonly state: ConnectionStateSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc - /** Large-body carrier; absent for the in-page fixture transport. */ - readonly backgroundUploads?: BackgroundUploadTransport /** Reset retry progression and replace the current attempt immediately. */ reconnect(): void /** @@ -195,9 +183,6 @@ export function apply(ctx: Context): void { const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) - const backgroundUploads = fixtureRpc === undefined - ? createBackgroundUploadTransport(transport?.fetch) - : undefined let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 @@ -252,7 +237,6 @@ export function apply(ctx: Context): void { }, }, rpc, - ...(backgroundUploads === undefined ? {} : { backgroundUploads }), reconnect() { owner?.controller.reconnect() }, diff --git a/packages/client/connection/tests/background-upload.client.spec.ts b/packages/client/connection/tests/background-upload.client.spec.ts deleted file mode 100644 index 04a73cf4b5..0000000000 --- a/packages/client/connection/tests/background-upload.client.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - backgroundUploadWorker, createBackgroundUploadTransport, -} from '../src/client/background-upload.ts' - -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -describe('background upload worker body', () => { - it('sends the Blob with credentials and reports progress, completion, and failure', () => { - const posted: unknown[] = [] - const scope: { - onmessage: ((event: MessageEvent<{ - url: string - body: Blob - headers: Readonly> - }>) => void) | null - postMessage(message: unknown): void - } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } } - const xhr: { - upload: { onprogress: ((event: ProgressEvent) => void) | null } - status: number - responseText: string - withCredentials: boolean - onload: ((event: ProgressEvent) => void) | null - onerror: ((event: ProgressEvent) => void) | null - open: ReturnType - setRequestHeader: ReturnType - send: ReturnType - } = { - upload: { onprogress: null }, - status: 201, - responseText: '{"ok":true}', - withCredentials: false, - onload: null, - onerror: null, - open: vi.fn(), - setRequestHeader: vi.fn(), - send: vi.fn(), - } - backgroundUploadWorker(scope, () => xhr as never) - const body = new Blob(['large']) - scope.onmessage?.({ - data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } }, - } as never) - expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload') - expect(xhr.withCredentials).toBe(true) - expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream') - expect(xhr.send).toHaveBeenCalledWith(body) - - xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent) - xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent) - xhr.onload?.({} as ProgressEvent) - xhr.onerror?.({} as ProgressEvent) - expect(posted).toEqual([ - { kind: 'progress', loaded: 2, total: 4 }, - { kind: 'progress', loaded: 3 }, - { kind: 'complete', status: 201, body: '{"ok":true}' }, - { kind: 'error', message: 'background upload transport failed' }, - ]) - }) - - it('uses the Worker globals when the emitted body supplies no test seams', () => { - const posted: unknown[] = [] - const scope: { - onmessage: ((event: MessageEvent) => void) | null - postMessage(message: unknown): void - } = { onmessage: null, postMessage: (message) => { posted.push(message) } } - const xhr = { - upload: { onprogress: null }, - status: 204, - responseText: '', - withCredentials: false, - onload: null, - onerror: null, - open: vi.fn(), - setRequestHeader: vi.fn(), - send: vi.fn(), - } - vi.stubGlobal('self', scope) - vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr })) - backgroundUploadWorker() - scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent) - expect(xhr.send).toHaveBeenCalledOnce() - }) -}) - -describe('background upload page transport', () => { - it('hands Blob bodies to a custom Host Worker transport without reading them', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) - const body = new Blob(['opaque']) - const fetch = vi.fn((_url: URL, _init?: RequestInit) => - Promise.resolve(new Response('accepted', { status: 202 }))) - const signal = new AbortController().signal - await expect(createBackgroundUploadTransport(fetch).post({ - path: '/api/upload', body, headers: { 'x-test': 'yes' }, signal, - })).resolves.toEqual({ status: 202, body: 'accepted' }) - expect(fetch).toHaveBeenCalledWith(new URL('https://preview.test/api/upload'), { - method: 'POST', headers: { 'x-test': 'yes' }, body, signal, - }) - - vi.stubGlobal('location', { origin: 'null' }) - await createBackgroundUploadTransport(fetch).post({ path: '/fallback', body }) - expect(fetch.mock.calls[1]?.[0]).toEqual(new URL('http://dsh.internal/fallback')) - expect(fetch.mock.calls[1]?.[1]).toEqual({ method: 'POST', body }) - }) - - it('fails loud when a served browser has no Worker implementation', async () => { - vi.stubGlobal('Worker', undefined) - await expect(createBackgroundUploadTransport().post({ path: '/upload', body: new Blob() })) - .rejects.toThrow('background upload requires Web Worker support') - }) - - it('forwards progress and completion from a dedicated Worker and then terminates it', async () => { - const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') - const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - class FakeWorker { - static last: FakeWorker | undefined - onmessage: ((event: MessageEvent) => void) | null = null - onerror: ((event: ErrorEvent) => void) | null = null - readonly postMessage = vi.fn() - readonly terminate = vi.fn() - constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } - } - vi.stubGlobal('Worker', FakeWorker) - vi.stubGlobal('location', { origin: 'https://harness.test' }) - const progress = vi.fn() - const body = new Blob(['bytes']) - const controller = new AbortController() - const pending = createBackgroundUploadTransport().post({ - path: '/api/upload', body, onProgress: progress, signal: controller.signal, - }) - const worker = FakeWorker.last - if (worker === undefined) throw new Error('worker missing') - expect(created).toHaveBeenCalledOnce() - expect(revoked).toHaveBeenCalledWith('blob:worker') - expect(worker.url).toBe('blob:worker') - expect(worker.options).toEqual({ name: 'dsh-file-upload' }) - expect(worker.postMessage).toHaveBeenCalledWith({ - url: 'https://harness.test/api/upload', body, headers: {}, - }) - worker.onmessage?.({ data: { kind: 'progress', loaded: 2 } } as MessageEvent) - worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) - worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) - worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent) - await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) - controller.abort() - expect(progress).toHaveBeenCalledWith({ loaded: 2 }) - expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 }) - expect(worker.terminate).toHaveBeenCalledOnce() - }) - - it('rejects worker messages, worker errors, and caller cancellation', async () => { - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - class FakeWorker { - static all: FakeWorker[] = [] - onmessage: ((event: MessageEvent) => void) | null = null - onerror: ((event: ErrorEvent) => void) | null = null - readonly postMessage = vi.fn() - readonly terminate = vi.fn() - constructor() { FakeWorker.all.push(this) } - } - vi.stubGlobal('Worker', FakeWorker) - const transport = createBackgroundUploadTransport() - - const reported = transport.post({ path: '/upload', body: new Blob() }) - FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) - await expect(reported).rejects.toThrow('network failed') - - const errored = transport.post({ path: '/upload', body: new Blob() }) - FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) - await expect(errored).rejects.toThrow('worker crashed') - - const unnamed = transport.post({ path: '/upload', body: new Blob() }) - FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) - await expect(unnamed).rejects.toThrow('background upload worker failed') - - const controller = new AbortController() - const aborted = transport.post({ path: '/upload', body: new Blob(), signal: controller.signal }) - controller.abort() - await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) - expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() - - const already = new AbortController() - already.abort() - await expect(transport.post({ path: '/upload', body: new Blob(), signal: already.signal })) - .rejects.toMatchObject({ name: 'AbortError' }) - expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() - }) -}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index a4549c4970..1ce037462f 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -8,7 +8,6 @@ "files": [ "src/api-path.ts", "src/client/api.ts", - "src/client/background-upload.ts", "src/client/connection.ts", "src/client/fixture.ts", "src/client/index.ts", diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml new file mode 100644 index 0000000000..123e508897 --- /dev/null +++ b/packages/client/file-upload/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/file-upload/README.md +README.md: d7cac384c1cef4f52f4f7c09838697d53bba7c4d +README.zh.md: 6ced413d1813c1808daef10e0cf283cd973eff68 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md new file mode 100644 index 0000000000..d7cac384c1 --- /dev/null +++ b/packages/client/file-upload/README.md @@ -0,0 +1,94 @@ +--- +description: "Background browser uploads for raw Blob and ReadableStream bodies, including worker transfer, progress, and cancellation." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-file-upload + +English | [中文](README.zh.md) + +## Summary + +This package lets browser features upload a `Blob` or `ReadableStream` without aggregating its bytes on the page thread. Served pages send each body through a dedicated Worker; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page reports the background carrier as unavailable so its Session adapter can keep using the generated in-memory Remote. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the Client plugin before a consumer that injects `fileUpload`, then call `ctx.fileUpload.post()` with a same-origin path and one raw body. + +```yaml +- id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' +``` + +The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Client plugin provides one inherited `ctx.fileUpload` service. Its provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Consumers inspect `ctx.fileUpload.available` before selecting it. Without a hook, each non-fixture request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. + +| File | Role | +|---|---| +| [`src/client/contract.ts`](src/client/contract.ts) | Cordis service request, response, progress, and page-hook types | +| [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations | +| [`src/client/index.ts`](src/client/index.ts) | Client plugin registration and `ctx.fileUpload` declaration | + +
+ +----- + + +## Further Exploration + +- [Connection](../connection/README.md) — authenticated RPC, exact Host routes, and connection generations. +- [Session Controller](../../api/session-controller/README.md) — the raw file-upload route and staged Session receipt. +- [Web Worker runtime](../../experimental/webworker-runtime/README.md) — the page-to-Host Worker request tunnel. +- [Client group map](../README.md) — browser services and UI feature packages. + +----- + + +## Model Experience + +None, as this package transfers browser request bodies and contributes no model input. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +These limits apply to the transport operation itself. + +- **Uploads are not resumable** — a failed or cancelled retry starts from the first byte. +- **Stream bodies are one-shot** — transferring a `ReadableStream` locks the caller's object, so retry requires a newly created stream. +- **Stream progress has no total** — callers receive consumed-byte counts because the stream API carries no byte length. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md new file mode 100644 index 0000000000..6ced413d18 --- /dev/null +++ b/packages/client/file-upload/README.zh.md @@ -0,0 +1,94 @@ +--- +description: "在浏览器后台上传原始 Blob 与 ReadableStream,包括 Worker 转交、进度和取消。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-file-upload + +[English](README.md) | 中文 + +## 概述 + +本包让浏览器功能上传 `Blob` 或 `ReadableStream`,同时避免在页面线程聚合全部字节。普通服务页面通过专用 Worker 发送每个请求体;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面会报告后台载体不可用,让 Session adapter 继续使用生成的内存 Remote。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在注入 `fileUpload` 的消费方之前挂载 Client 插件,再用同源路径和一个原始请求体调用 `ctx.fileUpload.post()`。 + +```yaml +- id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' +``` + +本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。消费者在选择服务前检查 `ctx.fileUpload.available`。没有该钩子时,每个非 fixture 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 + +| 文件 | 职责 | +|---|---| +| [`src/client/contract.ts`](src/client/contract.ts) | Cordis 服务的请求、响应、进度与页面钩子类型 | +| [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 | +| [`src/client/index.ts`](src/client/index.ts) | Client 插件注册与 `ctx.fileUpload` 声明 | + +
+ +----- + + +## 进一步探索 + +- [Connection](../connection/README.zh.md)——认证 RPC、Host 精确路由与 connection generation。 +- [Session Controller](../../api/session-controller/README.zh.md)——原始文件上传路由与按 Session 暂存的凭证。 +- [Web Worker runtime](../../experimental/webworker-runtime/README.zh.md)——页面到 Host Worker 的请求隧道。 +- [客户端组地图](../README.zh.md)——浏览器服务与 UI 功能包。 + +----- + + +## 模型体验 + +无。本包只传输浏览器请求体,不提供模型输入。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +以下限制适用于传输操作本身。 + +- **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。 +- **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。 +- **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json new file mode 100644 index 0000000000..d55aebb8c7 --- /dev/null +++ b/packages/client/file-upload/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-client-file-upload", + "description": "Browser background upload service for Blob and ReadableStream request bodies", + "version": "0.1.2-alpha.4", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/file-upload" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts new file mode 100644 index 0000000000..28ef800c9a --- /dev/null +++ b/packages/client/file-upload/src/client/contract.ts @@ -0,0 +1,49 @@ +/** Browser request body accepted by the background file-upload service. */ +export type FileUploadBody = Blob | ReadableStream + +/** Monotone byte progress reported while a browser body is consumed. */ +export interface FileUploadProgress { + readonly loaded: number + readonly total?: number +} + +/** One background upload request. */ +export interface FileUploadRequest { + readonly path: string + readonly body: FileUploadBody + readonly headers?: Readonly> + readonly signal?: AbortSignal + readonly onProgress?: (progress: FileUploadProgress) => void +} + +/** Small response returned after the service has sent the body. */ +export interface FileUploadResponse { + readonly status: number + readonly body: string +} + +/** Browser upload service inherited by every Client Cordis scope. */ +export interface FileUploadService { + /** Whether this page has a Host-backed background upload carrier. */ + readonly available: boolean + /** + * Post one Blob or one-shot byte stream without aggregating it on the page thread. + * The call consumes a stream body and rejects with `AbortError` when its signal fires. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: FileUploadRequest): Promise +} + +/** + * Fetch-shaped carrier installed by a page that owns its Host transport. + * @param input - absolute same-origin upload URL. + * @param init - raw request body, headers, and cancellation signal. + * @returns the Host response. + */ +export type FileUploadFetch = (input: URL, init: RequestInit) => Promise + +/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ +export interface ClientFileUploadHooks { + readonly fetch: FileUploadFetch +} diff --git a/packages/client/file-upload/src/client/index.ts b/packages/client/file-upload/src/client/index.ts new file mode 100644 index 0000000000..a1bff5131d --- /dev/null +++ b/packages/client/file-upload/src/client/index.ts @@ -0,0 +1,32 @@ +/** Browser background-upload Cordis service. */ + +import type { Context } from '@deepseek-ai/cordis' +import { FileUploadRuntime } from './runtime.ts' + +export type { + ClientFileUploadHooks, + FileUploadBody, + FileUploadFetch, + FileUploadProgress, + FileUploadRequest, + FileUploadResponse, + FileUploadService, +} from './contract.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Browser service for non-aggregating Blob and byte-stream uploads. */ + fileUpload: import('./contract.ts').FileUploadService + } +} + +/** This transport service has no Cordis dependencies. */ +export const inject: string[] = [] + +/** + * Provide the browser background-upload service. + * @param ctx - Client plugin context. + */ +export function apply(ctx: Context): void { + ctx.plugin(FileUploadRuntime) +} diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts new file mode 100644 index 0000000000..2e700ee37d --- /dev/null +++ b/packages/client/file-upload/src/client/runtime.ts @@ -0,0 +1,248 @@ +/** Background browser upload implementation for Blob and byte-stream bodies. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import type { + ClientFileUploadHooks, + FileUploadBody, + FileUploadFetch, + FileUploadRequest, + FileUploadResponse, + FileUploadService, +} from './contract.ts' + +interface UploadWorkerStart { + readonly url: string + readonly body: FileUploadBody + readonly headers: Readonly> +} + +type UploadWorkerOutput = + | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number } + | { readonly kind: 'complete'; readonly status: number; readonly body: string } + | { readonly kind: 'error'; readonly message: string } + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: UploadWorkerOutput): void +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } + status: number + responseText: string + withCredentials: boolean + onload: ((event: ProgressEvent) => void) | null + onerror: ((event: ProgressEvent) => void) | null + open(method: string, url: string): void + setRequestHeader(name: string, value: string): void + send(body: Blob): void +} + +type UploadWorkerFetch = ( + input: string, + init: RequestInit & { readonly duplex: 'half' }, +) => Promise + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for requests and progress messages. + * @param createXhr - XMLHttpRequest factory used for Blob progress. + * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies. + */ +export function fileUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), + doFetch: UploadWorkerFetch = (input, init) => fetch(input, init), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data + if (request.body instanceof Blob) { + const xhr = createXhr() + xhr.open('POST', request.url) + xhr.withCredentials = true + for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value) + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: 'progress', + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput) + } + xhr.onload = () => { + scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput) + } + xhr.onerror = () => { + scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput) + } + xhr.send(request.body) + return + } + if (!(request.body instanceof ReadableStream)) { + scope.postMessage({ kind: 'error', message: 'background upload worker received an invalid body' }) + return + } + const source = request.body + void (async () => { + const reader = source.getReader() + let loaded = 0 + const body = new ReadableStream({ + async pull(controller) { + const item = await reader.read() + if (item.done) { + controller.close() + return + } + if (!(item.value instanceof Uint8Array)) { + throw new TypeError('background upload stream produced a non-Uint8Array chunk') + } + loaded += item.value.byteLength + scope.postMessage({ kind: 'progress', loaded }) + controller.enqueue(item.value) + }, + async cancel(reason) { + await reader.cancel(reason) + }, + }) + const response = await doFetch(request.url, { + method: 'POST', + headers: request.headers, + credentials: 'include', + body, + duplex: 'half', + }) + scope.postMessage({ + kind: 'complete', + status: response.status, + body: await response.text(), + }) + })().catch((error: unknown) => { + scope.postMessage({ + kind: 'error', + message: error instanceof Error ? error.message : String(error), + }) + }) + } +} + +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + +interface FileUploadTransport { + post(request: FileUploadRequest): Promise +} + +/** Cordis service that owns one background carrier per upload operation. */ +export class FileUploadRuntime extends Service implements FileUploadService { + readonly available: boolean + private readonly transport: FileUploadTransport + + /** @param ctx - providing Client context. */ + constructor(ctx: Context) { + super(ctx, 'fileUpload') + const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ + this.available = hook !== undefined || !isFixturePage() + this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch) + } + + /** + * Post one body with the carrier selected before Cordis boot. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: FileUploadRequest): Promise { + if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode')) + return this.transport.post(request) + } +} + +function customTransport(customFetch: FileUploadFetch): FileUploadTransport { + return { + async post(request) { + const init: RequestInit & { duplex?: 'half' } = { + method: 'POST', + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}), + ...(request.signal === undefined ? {} : { signal: request.signal }), + } + const response = await customFetch(resolveUrl(request.path), init) + return { status: response.status, body: await response.text() } + }, + } +} + +function workerTransport(): FileUploadTransport { + return { + post(request) { + if (typeof Worker !== 'function') { + return Promise.reject(new Error('background upload requires Web Worker support')) + } + const workerUrl = URL.createObjectURL(new Blob([ + `(${fileUploadWorker.toString()})()`, + ], { type: 'text/javascript' })) + const worker = new Worker(workerUrl, { name: 'dsh-file-upload' }) + URL.revokeObjectURL(workerUrl) + return new Promise((resolve, reject) => { + let settled = false + const abort = (): void => { + settled = true + worker.terminate() + request.signal?.removeEventListener('abort', abort) + reject(new DOMException('The operation was aborted.', 'AbortError')) + } + const finish = (settle: () => void): void => { + if (settled) return + settled = true + request.signal?.removeEventListener('abort', abort) + worker.terminate() + settle() + } + worker.onmessage = (event: MessageEvent) => { + const output = event.data + if (output.kind === 'progress') { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }) + } else if (output.kind === 'complete') { + finish(() => { resolve({ status: output.status, body: output.body }) }) + } else { + finish(() => { reject(new Error(output.message)) }) + } + } + worker.onerror = (event) => { + finish(() => { reject(new Error(event.message || 'background upload worker failed')) }) + } + if (request.signal?.aborted === true) { + abort() + return + } + request.signal?.addEventListener('abort', abort, { once: true }) + const message: UploadWorkerStart = { + url: resolveUrl(request.path).href, + body: request.body, + headers: request.headers ?? {}, + } + if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body]) + else worker.postMessage(message) + }) + }, + } +} + +function resolveUrl(path: string): URL { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + const origin = typeof pageLocation === 'object' && pageLocation !== null + && 'origin' in pageLocation && typeof pageLocation.origin === 'string' + ? pageLocation.origin + : undefined + return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) +} + +function isFixturePage(): boolean { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + return typeof pageLocation === 'object' && pageLocation !== null + && 'search' in pageLocation && typeof pageLocation.search === 'string' + && new URLSearchParams(pageLocation.search).has('fixture') +} diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts new file mode 100644 index 0000000000..8cc5773cca --- /dev/null +++ b/packages/client/file-upload/src/index.ts @@ -0,0 +1,4 @@ +/** Host half of the browser-only file-upload service. */ + +/** No Host behavior; the Client half provides the browser upload service. */ +export function apply(): void {} diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts new file mode 100644 index 0000000000..aef58cecac --- /dev/null +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -0,0 +1,322 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply } from '../src/client/index.ts' +import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' +import type { ClientFileUploadHooks } from '../src/client/contract.ts' + +interface UploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + +afterEach(() => { + delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('file upload worker body', () => { + it('sends a Blob with credentials and reports progress, completion, and failure', () => { + const posted: unknown[] = [] + const scope: { + onmessage: ((event: MessageEvent<{ + url: string + body: Blob + headers: Readonly> + }>) => void) | null + postMessage(message: unknown): void + } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } } + const xhr = { + upload: { onprogress: null as ((event: ProgressEvent) => void) | null }, + status: 201, + responseText: '{"ok":true}', + withCredentials: false, + onload: null as ((event: ProgressEvent) => void) | null, + onerror: null as ((event: ProgressEvent) => void) | null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + fileUploadWorker(scope, () => xhr) + const body = new Blob(['large']) + scope.onmessage?.({ + data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } }, + } as never) + expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload') + expect(xhr.withCredentials).toBe(true) + expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream') + expect(xhr.send).toHaveBeenCalledWith(body) + + xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent) + xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent) + xhr.onload?.({} as ProgressEvent) + xhr.onerror?.({} as ProgressEvent) + expect(posted).toEqual([ + { kind: 'progress', loaded: 2, total: 4 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 201, body: '{"ok":true}' }, + { kind: 'error', message: 'background upload transport failed' }, + ]) + }) + + it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => { + const chunks: number[][] = [] + for await (const chunk of init.body as ReadableStream) chunks.push([...chunk]) + expect(chunks).toEqual([[1, 2], [3]]) + expect(init).toMatchObject({ + method: 'POST', + headers: { 'x-test': 'yes' }, + credentials: 'include', + duplex: 'half', + }) + return new Response('stored', { status: 202 }) + }) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2)) + controller.enqueue(Uint8Array.of(3)) + controller.close() + }, + }) + fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch) + scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never) + await vi.waitFor(() => { + expect(posted).toEqual([ + { kind: 'progress', loaded: 2 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 202, body: 'stored' }, + ]) + }) + }) + + it('reports invalid bodies, stream chunks, and fetch failures', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + fileUploadWorker(scope, () => { throw new Error('unused') }) + scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never) + expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }]) + + const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await new Response(init.body).arrayBuffer() + return new Response() + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ + kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk', + }) + }) + + const body = new ReadableStream({ start(controller) { controller.close() } }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + () => Promise.reject('offline'), + ) + scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' }) + }) + }) + + it('uses Worker globals when the emitted body supplies no test seams', () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const xhr = { + upload: { onprogress: null }, + status: 204, + responseText: '', + withCredentials: false, + onload: null, + onerror: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + vi.stubGlobal('self', scope) + vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr })) + fileUploadWorker() + scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent) + expect(xhr.send).toHaveBeenCalledOnce() + }) +}) + +describe('file upload service', () => { + it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const fetch = vi.fn((_url: URL, _init?: RequestInit) => + Promise.resolve(new Response('accepted', { status: 202 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const blob = new Blob(['opaque']) + const signal = new AbortController().signal + await expect(ctx.fileUpload.post({ + path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, + })).resolves.toEqual({ status: 202, body: 'accepted' }) + expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), { + method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal, + }) + + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await ctx.fileUpload.post({ path: '/stream', body: stream }) + expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), { + method: 'POST', body: stream, duplex: 'half', + }) + await fiber.dispose() + }) + + it('mounts through the plugin entry and resolves non-browser URLs', async () => { + vi.stubGlobal('location', { origin: 'null' }) + const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber + await ctx.fileUpload.post({ path: '/fallback', body: new Blob() }) + expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { + method: 'POST', body: expect.any(Blob), + }) + await fiber.dispose() + }) + + it('leaves the fixture on its generated Remote fallback', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + expect(ctx.fileUpload.available).toBe(false) + await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload is unavailable in fixture mode') + await fiber.dispose() + }) + + it('fails loud when a served browser has no Worker implementation', async () => { + vi.stubGlobal('Worker', undefined) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload requires Web Worker support') + await fiber.dispose() + }) + + it('forwards progress and completion from a dedicated Worker and then terminates it', async () => { + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('location', { origin: 'https://harness.test' }) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const progress = vi.fn() + const blob = new Blob(['bytes']) + const pending = ctx.fileUpload.post({ path: '/api/upload', body: blob, onProgress: progress }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(created).toHaveBeenCalledOnce() + expect(revoked).toHaveBeenCalledWith('blob:worker') + expect(worker.postMessage).toHaveBeenCalledWith({ + url: 'https://harness.test/api/upload', body: blob, headers: {}, + }) + worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 }) + expect(worker.terminate).toHaveBeenCalledOnce() + await fiber.dispose() + }) + + it('transfers stream ownership to the dedicated Worker', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const stream = new ReadableStream({ start(controller) { controller.close() } }) + const pending = ctx.fileUpload.post({ path: '/stream', body: stream }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream]) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + await fiber.dispose() + }) + + it('rejects worker messages, worker errors, and caller cancellation', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static all: FakeWorker[] = [] + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.all.push(this) } + } + vi.stubGlobal('Worker', FakeWorker) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + + const reported = ctx.fileUpload.post({ path: '/upload', body: new Blob() }) + FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) + await expect(reported).rejects.toThrow('network failed') + + const errored = ctx.fileUpload.post({ path: '/upload', body: new Blob() }) + FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) + await expect(errored).rejects.toThrow('worker crashed') + + const unnamed = ctx.fileUpload.post({ path: '/upload', body: new Blob() }) + FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) + await expect(unnamed).rejects.toThrow('background upload worker failed') + + const controller = new AbortController() + const aborted = ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: controller.signal }) + controller.abort() + await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() + + const already = new AbortController() + already.abort() + await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: already.signal })) + .rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() + await fiber.dispose() + }) +}) diff --git a/packages/client/file-upload/tsconfig.json b/packages/client/file-upload/tsconfig.json new file mode 100644 index 0000000000..4e6197e0d1 --- /dev/null +++ b/packages/client/file-upload/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/client/file-upload/tsdown.config.ts b/packages/client/file-upload/tsdown.config.ts new file mode 100644 index 0000000000..97292cd3cc --- /dev/null +++ b/packages/client/file-upload/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js']) diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 119e59602b..2afb8b1303 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -282,14 +282,14 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: type: 'image', image: { preview: { - url: attachment.previewUrl, - ...(attachment.name === undefined ? {} : { name: attachment.name }), - ...(attachment.width === undefined ? {} : { width: attachment.width }), - ...(attachment.height === undefined ? {} : { height: attachment.height }), + url: attachment.value.previewUrl, + ...(attachment.value.name === undefined ? {} : { name: attachment.value.name }), + ...(attachment.value.width === undefined ? {} : { width: attachment.value.width }), + ...(attachment.value.height === undefined ? {} : { height: attachment.value.height }), }, }, } - : { type: 'file', file: attachment.attachment }), + : { type: 'file', file: attachment.value }), [submission.attachments], ) return ( diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index a99d79a7f9..2c8fe220ea 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1081,7 +1081,9 @@ describe('ChatView', () => { placement: 'steering', time: 5_500, text: '带图纠偏', - attachments: [{ type: 'image', previewUrl: 'blob:steer-preview', name: 'steer.png' }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:steer-preview', name: 'steer.png' }, + }], }], }, ) @@ -1150,8 +1152,10 @@ describe('ChatView', () => { time: 7_000, text: '', attachments: [ - { type: 'image', previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, - { type: 'image', previewUrl: 'blob:echo-b' }, + { + type: 'image', value: { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }, + { type: 'image', value: { previewUrl: 'blob:echo-b' } }, ], }], }, @@ -1190,12 +1194,12 @@ describe('ChatView', () => { time: 7_500, text: '', attachments: [ - { type: 'image', previewUrl: 'blob:first', name: 'first.png' }, + { type: 'image', value: { previewUrl: 'blob:first', name: 'first.png' } }, { type: 'file', - attachment: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 }, + value: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 }, }, - { type: 'image', previewUrl: 'blob:last', name: 'last.png' }, + { type: 'image', value: { previewUrl: 'blob:last', name: 'last.png' } }, ], }], }, diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index abafa2bd7e..2d0d5d876e 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -308,17 +308,17 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que {submission.attachments.map((attachment, index) => attachment.type === 'image' ? ( {t('queue.image')} ) : ( ))} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2070744c4a..2e33fe8a26 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -246,12 +246,14 @@ export class ConversationController extends Service implements IConversation { const pendingAttachments = attachments.map(attachment => attachment.kind === 'image' ? { type: 'image' as const, - previewUrl: attachment.previewUrl, - ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), - ...(attachment.width === undefined ? {} : { width: attachment.width }), - ...(attachment.height === undefined ? {} : { height: attachment.height }), + value: { + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + }, } - : { type: 'file' as const, attachment: uploadFor(attachment).file }) + : { type: 'file' as const, value: uploadFor(attachment).file }) const serializeAttachments = (): Promise[0]> => Promise.all( attachments.map(async attachment => attachment.kind === 'image' ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 270032799f..98225cb44d 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -125,10 +125,13 @@ describe('QueueDock', () => { time: 1, text: '等待上传', attachments: [ - { type: 'image' as const, previewUrl: 'blob:queue-preview', name: 'queue.png' }, + { + type: 'image' as const, + value: { previewUrl: 'blob:queue-preview', name: 'queue.png' }, + }, { type: 'file' as const, - attachment: { + value: { attachmentId: 'file-local' as never, name: 'notes.txt', bytes: 2447 * 1024 * 1024, diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 65dd3b7693..806fb4250d 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -483,7 +483,10 @@ describe('sendSession submission echo', () => { expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ mode: 'queue', text: '带图', - attachments: [expect.objectContaining({ type: 'image', previewUrl: 'blob:echo-1', name: 'a.png' })], + attachments: [{ + type: 'image', + value: expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' }), + }], })) expect(b.prompt).not.toHaveBeenCalled() await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) @@ -531,9 +534,9 @@ describe('sendSession submission echo', () => { expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ mode: 'steer', attachments: [ - expect.objectContaining({ type: 'image', name: 'first.png' }), - { type: 'file', attachment: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, - expect.objectContaining({ type: 'image', name: 'last.png' }), + { type: 'image', value: expect.objectContaining({ name: 'first.png' }) }, + { type: 'file', value: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, + { type: 'image', value: expect.objectContaining({ name: 'last.png' }) }, ], })) await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 036fa37bd8..10a2ecc5d2 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -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/experimental/webworker-runtime/README.md -README.md: bb561f2e9ec02d0a322e9d89919fc9f8515fb64a -README.zh.md: c90c476c95275220d5f801006b9d557f461d0bef +README.md: b5c9370bbff33e4385e08c299fc9c1a7669f8e5e +README.zh.md: 04eee0bfb133bf020fc8989ddb98e54c1dfadf34 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index bb561f2e9e..b5c9370bbf 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -28,7 +28,7 @@ Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. Request frames preserve Blob bodies through structured clone; the Host Worker streams their chunks into the route, so neither browser thread creates a complete byte array for a generic-file upload. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index c90c476c95..04eee0bfb1 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。请求帧通过结构化克隆保留 Blob body,Host Worker 将其逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 57b33b5d22..0c181077b9 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 4d5a1d28e0..6afb527705 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -119,11 +119,14 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne } } -/** Keep opaque Blobs clone-cheap; normalize text and typed arrays to transferable bytes. */ -function toTunnelBody(body: RequestInit['body']): ArrayBuffer | Blob | undefined { +/** Keep opaque Blobs and transferable streams intact; normalize other bodies to bytes. */ +function toTunnelBody( + body: RequestInit['body'], +): ArrayBuffer | Blob | ReadableStream | undefined { if (body === undefined || body === null) return undefined if (typeof body === 'string') return encoder.encode(body).buffer if (body instanceof Blob) return body + if (body instanceof ReadableStream) return body as ReadableStream if (body instanceof ArrayBuffer) return body if (ArrayBuffer.isView(body)) { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) @@ -198,21 +201,21 @@ export class WorkerTunnel { // must not reach the worker, where a write-shaped route would still run. if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError') const id = this.nextId++ + const body = init?.body === undefined || init.body === null ? undefined : toTunnelBody(init.body) const frame: RequestFrame = { t: 'req', id, method: init?.method ?? 'GET', url: new URL(input, globalThis.location.origin).toString(), headers: Object.fromEntries(new Headers(init?.headers).entries()), - ...(init?.body === undefined || init.body === null - ? {} - : { body: toTunnelBody(init.body) }), + ...(body === undefined ? {} : { body }), } const response = new Promise((resolve, reject) => { this.unary.set(id, { resolve, reject }) }) this.inFlight.set(id, `${frame.method} ${frame.url}`) - this.worker.postMessage(frame) + if (body instanceof ReadableStream) this.worker.postMessage(frame, [body]) + else this.worker.postMessage(frame) if (signal === undefined || signal === null) return await response const raced = this.rejectOnAbort(id, signal) try { diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 7f4af6e1a9..137776853c 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -9,6 +9,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client */ import { IMAGE_FILE_NAME } from '../image-layout.ts' +import type { ClientFileUploadHooks } from '@deepseek-ai/dsh-client-file-upload/client' import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' @@ -33,6 +34,11 @@ interface ClientTransportGlobal { } } +/** Upload hook consumed by the independent Client file-upload service. */ +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + /** Inputs for {@link connectWorkerHost}. */ export interface WorkerHostConnectOptions { /** @@ -151,6 +157,9 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn // the privileged surface stays reachable off loopback authorities. ownsHost: true, } + ;(globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: (input, init) => tunnel.fetch(input, init), + } await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src)) ready.resolve() return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) } diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index e591fac007..01d399d4e8 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -7,14 +7,14 @@ /** Request identifier minted by the page. */ export type TunnelRequestId = string | number -/** One request; `body` carries the raw bytes for methods that have one. */ +/** One request; `body` carries cloneable or transferable raw bytes. */ export interface TunnelRequestFrame { readonly t: 'req' readonly id: TunnelRequestId readonly method: string readonly url: string readonly headers: Readonly> - readonly body?: ArrayBuffer | Blob | undefined + readonly body?: ArrayBuffer | Blob | ReadableStream | undefined } /** Open one Gateway Remote stream over the worker-local carrier. */ @@ -171,8 +171,11 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof value === 'string') headers[key.toLowerCase()] = value } const body = frame.body - if (body !== undefined && !(body instanceof ArrayBuffer) && !(body instanceof Blob)) { - throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer or Blob`) + if (body !== undefined + && !(body instanceof ArrayBuffer) + && !(body instanceof Blob) + && !(body instanceof ReadableStream)) { + throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer, Blob, or ReadableStream`) } return { t: 'req', id, method: frame.method, url: frame.url, headers, body } } diff --git a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts index 29e5ea4926..a45b4164d8 100644 --- a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts +++ b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts @@ -74,6 +74,16 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons } return } + if (frame.body instanceof ReadableStream) { + for await (const chunk of frame.body) { + if (aborted) return + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('webworker tunnel: request stream produced a non-Uint8Array chunk') + } + if (chunk.byteLength > 0) yield chunk + } + return + } if (aborted || frame.body.byteLength === 0) return yield new Uint8Array(frame.body) }, diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts index 1aced9e508..4113bfd25b 100644 --- a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -22,7 +22,7 @@ describe('tunnel init frame', () => { }) describe('tunnel request bodies', () => { - it('accepts ArrayBuffer and Blob bodies and rejects other structured-clone values', () => { + it('accepts ArrayBuffer, Blob, and ReadableStream bodies and rejects other values', () => { const bytes = Uint8Array.of(1, 2).buffer const blob = new Blob(['large']) expect(parseInboundFrame({ @@ -31,8 +31,12 @@ describe('tunnel request bodies', () => { expect(parseInboundFrame({ t: 'req', id: 2, method: 'POST', url: '/blob', headers: {}, body: blob, })).toMatchObject({ body: blob }) + const stream = new ReadableStream() + expect(parseInboundFrame({ + t: 'req', id: 3, method: 'POST', url: '/stream', headers: {}, body: stream, + })).toMatchObject({ body: stream }) expect(() => parseInboundFrame({ - t: 'req', id: 3, method: 'POST', url: '/bad', headers: {}, body: 'large', - })).toThrow('body must be an ArrayBuffer or Blob') + t: 'req', id: 4, method: 'POST', url: '/bad', headers: {}, body: 'large', + })).toThrow('body must be an ArrayBuffer, Blob, or ReadableStream') }) }) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index 92909695c7..5f23d51653 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -33,27 +33,43 @@ type StubListener = (event: { data?: unknown; message?: string }) => void function stubWorker(): { worker: Worker sent: { t: string; id: number }[] + transfers: Transferable[][] deliver: (frame: unknown) => void fail: (message: string) => void } { const listeners: StubListener[] = [] const errorListeners: StubListener[] = [] const sent: { t: string; id: number }[] = [] + const transfers: Transferable[][] = [] const worker = { addEventListener: (type: string, listener: StubListener) => { if (type === 'message') listeners.push(listener) if (type === 'error') errorListeners.push(listener) }, - postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) }, + postMessage: (frame: unknown, transfer?: Transferable[]) => { + sent.push(frame as { t: string; id: number }) + transfers.push(transfer ?? []) + }, } as unknown as Worker return { worker, sent, + transfers, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) }, fail: (message) => { for (const listener of errorListeners) listener({ message }) }, } } +// ReadableStream request bodies transfer ownership instead of copying chunks on the page. +{ + const { worker, sent, transfers } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const body = new ReadableStream({ start(controller) { controller.close() } }) + void tunnel.fetch('/upload', { method: 'POST', body, duplex: 'half' } as RequestInit) + check('a stream request body stays intact', (sent[0] as unknown as { body: unknown }).body, body) + check('a stream request body is transferred', transfers[0], [body]) +} + // The opening frame preserves overlay order for deterministic pre-boot mounts. { const { worker, sent } = stubWorker() diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index 05597849f3..7ca4ba6260 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -98,6 +98,36 @@ describe('worker tunnel Blob requests', () => { }) }) +describe('worker tunnel ReadableStream requests', () => { + it('streams transferred chunks through the Host Worker route', async () => { + const frames: TunnelOutboundFrame[] = [] + const seen: Uint8Array[] = [] + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes(111, 110, 101)) + controller.enqueue(bytes(116, 119, 111)) + controller.close() + }, + }) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve(async (request, response) => { + for await (const chunk of request as AsyncIterable) seen.push(chunk) + const res = response as { writeHead(status: number): void; end(body: string): void } + res.writeHead(200) + res.end('stored') + }), + }) + server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })())) + server.handleMessage({ + t: 'req', id: 10, method: 'POST', url: 'http://localhost/upload', headers: {}, body, + }) + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['one', 'two']) + expect(frames[0]).toMatchObject({ t: 'res', id: 10, status: 200 }) + }) +}) + describe('worker tunnel logical streams', () => { it('drains a pre-boot open through the worker-local Gateway seam', async () => { const { server, frames } = harness() diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index f28341d750..ee06b09fcd 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../../client/file-upload" + }, { "path": "../../host/webserver" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ad71a12e..9a533b5878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -780,6 +780,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store @@ -1467,6 +1470,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-hmr': specifier: workspace:^ version: link:../../client/hmr @@ -1722,6 +1728,12 @@ importers: specifier: workspace:^ version: link:../../util/values + packages/client/file-upload: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + packages/client/hmr: dependencies: '@deepseek-ai/schemastery': @@ -4882,6 +4894,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 83fc17c43e..c52a2ce068 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -153,6 +153,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', + fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API', uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', diff --git a/tsconfig.base.json b/tsconfig.base.json index b6daa12c06..118d28e2b9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -157,6 +157,8 @@ "@deepseek-ai/dsh-client-ui-session": ["./packages/client/ui-session/src"], "@deepseek-ai/dsh-client-ui-session/client": ["./packages/client/ui-session/src/client"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], + "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"], + "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2634de2cdd..6675d98f44 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -54,6 +54,7 @@ { "path": "./packages/experimental/inspector/tsconfig.client.json" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, + { "path": "./packages/client/file-upload" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, From 6cf7b1e34f4e362602fe8ab23af7a3288d2108d6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:00:14 +0800 Subject: [PATCH 09/37] chore(client): refresh generated API catalog --- packages/client/file-upload/package.json | 2 +- .../src/client/contract/assistant-content.ts | 2 +- .../ui-chat/src/client/contract/slots.ts | 7 ++--- .../src/client/contract/turn-metrics.ts | 4 ++- .../client/conversation-nodes/assistant.ts | 4 +-- .../chat-snapshot-builder.ts | 7 ++--- .../src/client/conversation-nodes/command.ts | 3 +- .../client/conversation-nodes/compaction.ts | 3 +- .../src/client/conversation-nodes/fallback.ts | 5 ++-- .../src/client/conversation-nodes/message.ts | 5 ++-- .../src/client/conversation-nodes/partial.ts | 4 ++- .../src/client/conversation-nodes/retry.ts | 3 +- .../src/client/conversation-nodes/tool.ts | 4 +-- .../client/conversation-nodes/turn-error.ts | 3 +- .../conversation-nodes/turn-max-tokens.ts | 3 +- .../src/client/model/conversation-context.ts | 5 ++-- .../src/client/model/tool-call-tree.ts | 2 +- .../src/client/api-catalog.ts | 30 +++++++------------ .../verify-package-readme-model-experience.ts | 1 + 19 files changed, 45 insertions(+), 52 deletions(-) diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index d55aebb8c7..9cbc484b84 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Browser background upload service for Blob and ReadableStream request bodies", - "version": "0.1.2-alpha.4", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/contract/assistant-content.ts b/packages/client/ui-chat/src/client/contract/assistant-content.ts index e06c025873..18beac1cbf 100644 --- a/packages/client/ui-chat/src/client/contract/assistant-content.ts +++ b/packages/client/ui-chat/src/client/contract/assistant-content.ts @@ -1,4 +1,4 @@ -import type { AssistantBlock } from './snapshot.ts' +import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' /** * Test whether Assistant blocks contain a user-facing reply rather than only diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 12557c1c73..eb2ed7cf83 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -2,8 +2,8 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { - ConversationLocationDataStore, ConversationTurnDataMap, - MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, + CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, + MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, ToolCallBlock, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -16,8 +16,7 @@ import type { createChatStore } from '../stores.ts' import type { ToolCallId, SelectionTarget } from './store.ts' import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { - ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, CommandNode, - CompactionSummaryNode, ToolCallBlock, + ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, } from './snapshot.ts' import type { TurnProcessSpec } from './turn-process.ts' import type { TranscriptViewMode } from '../../chat-settings.ts' diff --git a/packages/client/ui-chat/src/client/contract/turn-metrics.ts b/packages/client/ui-chat/src/client/contract/turn-metrics.ts index 2f8f0eabbc..5880a92af5 100644 --- a/packages/client/ui-chat/src/client/contract/turn-metrics.ts +++ b/packages/client/ui-chat/src/client/contract/turn-metrics.ts @@ -1,6 +1,8 @@ // Latency/throughput folds shared by the settled turn footer and StatsLine. -import type { AssistantMessageNode, ConversationNode } from './snapshot.ts' +import type { + AssistantMessageNode, ConversationNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Latency and decode-throughput readings for one turn's footer. */ export interface TurnMetrics { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 0b23226cde..02dd7168df 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -1,12 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types' import type { - ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData } from '../contract/chat-nodes.ts' -import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' import { emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 6fa7671da6..64d74f28d8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,15 +1,14 @@ import type { Context } from '@deepseek-ai/cordis' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { - ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, - ChatTurnNavigationIndex, ChatTurnProcessPresentation, ConversationNode, LegacyConversationSlice, - PartialAssistant, RunningToolCall, TurnNavigationItem, + ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/command.ts b/packages/client/ui-chat/src/client/conversation-nodes/command.ts index b4723d78fa..e21f50c5f5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/command.ts @@ -1,13 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint' import type {} from '@deepseek-ai/dsh-compaction/types' import type {} from '@deepseek-ai/dsh-commands/types' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' -import type { CommandNode, CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 5874c7bd61..9612f08741 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,9 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index 5c5a77c82c..33cc5129a5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 1927a468c5..792eab2f1c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' import { contextForm, contextProvenance } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index ea52a3a565..9761ac93cd 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,5 +1,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' +import type { + AssistantBlock, PartialAssistant, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts' /** diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index eeaf8d461b..d721821f22 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,10 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationMatch, ConversationNodeDefinition, + ConversationLocation, ConversationMatch, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' -import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index b1f675c4e4..acfcb4bca8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,11 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, + ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' -import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index e3b41095ce..941f23ac43 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnErrorNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { displayFailure } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 31508ab77c..746d00bc95 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/model/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts index 7f8116ffcc..442c455435 100644 --- a/packages/client/ui-chat/src/client/model/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,6 @@ -import type { ConversationNode } from '../contract/snapshot.ts' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNode, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index ce311f5291..878a78e63a 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -2,7 +2,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '../contract/snapshot.ts' +} from '@deepseek-ai/dsh-client-ui-conversation/client' interface ProjectedBlock { source: ToolCallBlock diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index b3e525a561..1e08d7b349 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -433,22 +433,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentContext', declaration: 'export type AgentContext = Omit & {\n readonly remote: ClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};', }, - { - name: 'BackgroundUploadProgress', - declaration: 'export interface BackgroundUploadProgress {\n readonly loaded: number;\n readonly total?: number;\n}', - }, - { - name: 'BackgroundUploadRequest', - declaration: 'export interface BackgroundUploadRequest {\n readonly path: string;\n readonly body: Blob;\n readonly headers?: Readonly>;\n readonly signal?: AbortSignal;\n readonly onProgress?: (progress: BackgroundUploadProgress) => void;\n}', - }, - { - name: 'BackgroundUploadResponse', - declaration: 'export interface BackgroundUploadResponse {\n readonly status: number;\n readonly body: string;\n}', - }, - { - name: 'BackgroundUploadTransport', - declaration: 'export interface BackgroundUploadTransport {\n post(request: BackgroundUploadRequest): Promise;\n}', - }, { name: 'BakedActions', declaration: 'export type BakedActions> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};', @@ -515,7 +499,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n readonly backgroundUploads?: BackgroundUploadTransport;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', @@ -579,7 +563,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n uploadFile(data: Blob | Uint8Array, name?: string, signal?: AbortSignal, onProgress?: (progress: {\n readonly loaded: number;\n readonly total?: number;\n }) => void): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n uploadFile(data: Blob | Uint8Array | ReadableStream, name?: string, signal?: AbortSignal, onProgress?: (progress: {\n readonly loaded: number;\n readonly total?: number;\n }) => void): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyedHooksSources', @@ -651,12 +635,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmissionAttachment', - declaration: 'export type PendingSubmissionAttachment = ({\n readonly type: \'image\';\n} & PendingSubmissionImage) | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', + declaration: 'export type PendingSubmissionAttachment = PendingSubmissionImageAttachment | PendingSubmissionFileAttachment;', + }, + { + name: 'PendingSubmissionFileAttachment', + declaration: 'export interface PendingSubmissionFileAttachment {\n readonly type: \'file\';\n readonly value: FileAttachmentRef;\n}', }, { name: 'PendingSubmissionImage', declaration: 'export interface PendingSubmissionImage {\n readonly previewUrl: string;\n readonly name?: string;\n readonly width?: number;\n readonly height?: number;\n}', }, + { + name: 'PendingSubmissionImageAttachment', + declaration: 'export interface PendingSubmissionImageAttachment {\n readonly type: \'image\';\n readonly value: PendingSubmissionImage;\n}', + }, { name: 'PendingSubmissionPlacement', declaration: 'export type PendingSubmissionPlacement = \'transcript\' | \'queued\' | \'steering\';', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 565693d35f..f514ba8c7e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -78,6 +78,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' }, 'packages/client/ui-session': { kind: 'none', reason: 'Browser-side Session adapter; registers nothing model-facing.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, 'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The private Web layer delegates model composition to the Host-side Agent Teams profile.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, From 4647dbf8ff4f0b20b7bdd9b9a2366157f3326c8b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:01:37 +0800 Subject: [PATCH 10/37] fix(ui-approval): type pending approval discriminator --- packages/client/ui-approval/src/client/contract/slots.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 545260b9ae..99296e0c92 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -68,7 +68,7 @@ let nextApprovalKey = 0 /** One answerable Client presentation of a pending Host waterfall. */ export class PendingApproval { /** Domain discriminator used by Session pending-interaction consumers. */ - readonly kind = 'approval' as const + readonly kind: 'approval' /** Opaque render identity and one-shot remount axis. */ readonly key: string /** Tool requesting the decision. */ @@ -92,6 +92,7 @@ export class PendingApproval { * @param request - Host approval request projected through the Remote Event. */ constructor(readonly sessionId: SessionId, request: ApprovalPresentationRequest) { + this.kind = 'approval' nextApprovalKey += 1 this.key = `approval:${String(nextApprovalKey)}` this.toolName = request.toolName From 6acc828f81588db851b51066085cb47b3b95fe71 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:03:53 +0800 Subject: [PATCH 11/37] test(client): widen upload carrier fixtures --- packages/api/session-controller/tests/session.client.spec.ts | 2 +- packages/client/file-upload/tests/file-upload.client.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 341046c80f..b307c46f7d 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -102,7 +102,7 @@ describe('Session file upload', () => { }) it('preserves a background business failure and supports unnamed files without observers', async () => { - const post = vi.fn((_request: { readonly body: Blob }) => Promise.resolve({ + const post = vi.fn(() => Promise.resolve({ status: 200, body: JSON.stringify({ ok: false, diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index aef58cecac..dc71993334 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply } from '../src/client/index.ts' import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' -import type { ClientFileUploadHooks } from '../src/client/contract.ts' +import type { ClientFileUploadHooks, FileUploadBody } from '../src/client/contract.ts' interface UploadGlobal { __DSH_FILE_UPLOAD__?: ClientFileUploadHooks @@ -20,7 +20,7 @@ describe('file upload worker body', () => { const scope: { onmessage: ((event: MessageEvent<{ url: string - body: Blob + body: FileUploadBody headers: Readonly> }>) => void) | null postMessage(message: unknown): void From 7a99e70372ca620e5054e25e6b2d5e58bc827420 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:11:41 +0800 Subject: [PATCH 12/37] fix(client): satisfy upload package gates --- packages/client/file-upload/README.i18n.yaml | 4 +- packages/client/file-upload/README.md | 2 + packages/client/file-upload/README.zh.md | 2 + .../tests/file-upload.client.spec.ts | 7 ++- .../service-orchestration.client.spec.ts | 63 ++++++++++++------- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index 123e508897..4dfc341b3f 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -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/file-upload/README.md -README.md: d7cac384c1cef4f52f4f7c09838697d53bba7c4d -README.zh.md: 6ced413d1813c1808daef10e0cf283cd973eff68 +README.md: 9abd4106dbd7cd1cb9671f8f830952f6faff11c6 +README.zh.md: 4ad14ca5a91acd482f54cab74fb974208a264a5a diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index d7cac384c1..9abd4106db 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -52,6 +52,8 @@ The Client plugin provides one inherited `ctx.fileUpload` service. Its provider +**Runtime invariant:** No companion is published. Each request uses exactly one selected carrier, and fixture or unsupported-browser requests fail before the body is sent. + ----- diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index 6ced413d18..4ad14ca5a9 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -52,6 +52,8 @@ Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其 +**运行时不变式:** 不发布伴生入口。每个请求只使用一个已选定载体;fixture 或不支持的浏览器请求会在发送请求体前失败。 + ----- diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index dc71993334..0545b6ce29 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -124,7 +124,7 @@ describe('file upload worker body', () => { fileUploadWorker( scope, () => { throw new Error('unused') }, - () => Promise.reject('offline'), + () => Promise.reject(new Error('offline')), ) scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never) await vi.waitFor(() => { @@ -190,9 +190,10 @@ describe('file upload service', () => { const ctx = new Context() const fiber = ctx.plugin({ apply }) await fiber - await ctx.fileUpload.post({ path: '/fallback', body: new Blob() }) + const body = new Blob() + await ctx.fileUpload.post({ path: '/fallback', body }) expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { - method: 'POST', body: expect.any(Blob), + method: 'POST', body, }) await fiber.dispose() }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 806fb4250d..3582e0af61 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -6,7 +6,9 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' +import type { + BeginSubmissionInput, PendingSubmissionRetirement, QueuedMessage, +} from '@deepseek-ai/dsh-api-session-controller/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' import { ConversationController } from '../src/client/service.ts' @@ -453,9 +455,9 @@ describe('sendSession submission echo', () => { /** Bench with an observable beginSubmission on the session face. */ async function echoBench() { const b = await bench() - const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} + const retire: { onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined } = {} const abandon = vi.fn() - const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + const beginSubmission = vi.fn((input: BeginSubmissionInput) => { retire.onRetire = input.onRetire return { requestId: 'req-echo' as never, abandon } }) @@ -480,14 +482,12 @@ describe('sendSession submission echo', () => { const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') // Synchronous: the echo is registered before any encoding starts. - expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ - mode: 'queue', - text: '带图', - attachments: [{ - type: 'image', - value: expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' }), - }], - })) + const echo = b.beginSubmission.mock.calls[0]?.[0] + expect(echo?.mode).toBe('queue') + expect(echo?.text).toBe('带图') + expect(echo?.attachments).toHaveLength(1) + expect(echo?.attachments[0]?.type).toBe('image') + expect(echo?.attachments[0]?.value).toMatchObject({ previewUrl: 'blob:echo-1', name: 'a.png' }) expect(b.prompt).not.toHaveBeenCalled() await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) expect(b.prompt).toHaveBeenCalledWith( @@ -531,14 +531,15 @@ describe('sendSession submission echo', () => { expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready') }) const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer') - expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ - mode: 'steer', - attachments: [ - { type: 'image', value: expect.objectContaining({ name: 'first.png' }) }, - { type: 'file', value: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, - { type: 'image', value: expect.objectContaining({ name: 'last.png' }) }, - ], - })) + const echo = b.beginSubmission.mock.calls[0]?.[0] + expect(echo?.mode).toBe('steer') + expect(echo?.attachments.map(attachment => attachment.type === 'image' + ? { type: attachment.type, name: attachment.value.name } + : { type: attachment.type, value: attachment.value })).toEqual([ + { type: 'image', name: 'first.png' }, + { type: 'file', value: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, + { type: 'image', name: 'last.png' }, + ]) await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) expect(b.prompt.mock.calls[0]?.[0]).toEqual([ { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'first.png' }, @@ -549,9 +550,21 @@ describe('sendSession submission echo', () => { b.retire.onRetire?.({ reason: 'observed', attachments: [ - { attachmentId: 'image-first', mediaType: 'image/png' }, - { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 }, - { attachmentId: 'image-last', mediaType: 'image/png' }, + { + attachmentId: 'image-first' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, + { + attachmentId: 'image-last' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, ], }) await expect(sending).resolves.toEqual({ kind: 'success' }) @@ -598,7 +611,11 @@ describe('sendSession submission echo', () => { const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) const ref = { - attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, + attachmentId: 'att-1' as never, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, } b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) await expect(sending).resolves.toEqual({ kind: 'success' }) From 4ed33aa581b32537cfde24f6effabc804d35abe6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:20:25 +0800 Subject: [PATCH 13/37] chore(client): refresh slot source locations --- .../cordis-client-runner/src/client/slot-catalog.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 2b25d84a65..78b9f15ce0 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:228', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:216', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', }, { key: 'conversation.chat.node', @@ -314,7 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.chat.turnTail', @@ -359,7 +359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:222', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', }, { key: 'conversation.composer', @@ -533,7 +533,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:234', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:233', }, { key: 'conversation.hero.agentPreset', @@ -1012,7 +1012,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:210', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', }, { key: 'conversation.session', From b7e5d0b90ef7840e8e9f16ebb204dafb3b6d6c93 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:25:05 +0800 Subject: [PATCH 14/37] docs(client): refresh generated catalogs --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 2 ++ docs/module-graph.zh.md | 2 ++ 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 50c63952a5..239a58feec 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: f213f06619d238b771656ecbcaeb96ef8275c0aa -config-catalog.zh.md: 34d87e600df17694f78947678b2feec4774b9578 +config-catalog.md: 6d86df7b879e8ac23aebf300a8fdfec6b7459b6e +config-catalog.zh.md: 2e5bb999254d73b3e4589bad98104bba21d14383 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f213f06619..6d86df7b87 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3337,6 +3337,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 34d87e600d..2e5bb99925 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3339,6 +3339,7 @@ export interface Config { - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8584cb2c11..da03991c6b 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: b85f1ededf485df0fa49e049be08c47986088496 -module-graph.zh.md: 5ffc28de240f5fc60fb2dcf957eb2d3cf2b1e6da +module-graph.md: 44569324d9e2936da8a21bdc4e81b2bec0d2b553 +module-graph.zh.md: e4ae699e3681761e4fb4ffc806edfac994008f13 diff --git a/docs/module-graph.md b/docs/module-graph.md index b85f1ededf..44569324d9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -132,6 +132,7 @@ flowchart TD end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] + pkg_client_file_upload["client-file-upload"] pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] @@ -1167,6 +1168,7 @@ flowchart TD | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | | [`client-connection`](../packages/client/connection) | `client` | — | +| [`client-file-upload`](../packages/client/file-upload) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5ffc28de24..e4ae699e36 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -134,6 +134,7 @@ flowchart TD end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] + pkg_client_file_upload["client-file-upload"] pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] @@ -1169,6 +1170,7 @@ flowchart TD | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | | [`client-connection`](../packages/client/connection) | `client` | — | +| [`client-file-upload`](../packages/client/file-upload) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | From ee23db68cf7991a1f6d35416795683c3e6e27e1b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:43:07 +0800 Subject: [PATCH 15/37] test(client): cover background upload seams --- .../tests/client-apply.client.spec.ts | 4 ++ .../tests/file-upload.client.spec.ts | 65 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 49bc2c9245..e1c28d8bce 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -66,6 +66,10 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { start: () => ({ stop: () => {} }), } ctx.reflect.provide('connection', connection) + ctx.reflect.provide('fileUpload', { + available: true, + post: () => Promise.reject(new Error('unexpected file upload')), + }) ctx.reflect.provide('remote', { ...remote, $stream: (options: RemoteStreamOptions) => ( diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 0545b6ce29..e8344acf6d 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply as applyHost } from '../src/index.ts' import { apply } from '../src/client/index.ts' import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' import type { ClientFileUploadHooks, FileUploadBody } from '../src/client/contract.ts' @@ -94,6 +95,29 @@ describe('file upload worker body', () => { }) }) + it('propagates cancellation from the fetch body to the source stream', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await (init.body as ReadableStream).cancel('fetch stopped') + return new Response('cancelled') + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: source, headers: {} } } as never) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith('fetch stopped') + expect(posted.at(-1)).toEqual({ kind: 'complete', status: 200, body: 'cancelled' }) + }) + }) + it('reports invalid bodies, stream chunks, and fetch failures', async () => { const posted: unknown[] = [] const scope = { @@ -130,9 +154,25 @@ describe('file upload worker body', () => { await vi.waitFor(() => { expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' }) }) + + const failedSource = new ReadableStream({ + start(controller) { controller.error('source failed') }, + }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await (init.body as ReadableStream).getReader().read() + return new Response() + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: failedSource, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ kind: 'error', message: 'source failed' }) + }) }) - it('uses Worker globals when the emitted body supplies no test seams', () => { + it('uses Worker globals when the emitted body supplies no test seams', async () => { const posted: unknown[] = [] const scope = { onmessage: null as ((event: MessageEvent) => void) | null, @@ -154,10 +194,27 @@ describe('file upload worker body', () => { fileUploadWorker() scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent) expect(xhr.send).toHaveBeenCalledOnce() + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + await new Response(init.body).arrayBuffer() + return new Response(null, { status: 204 }) + }) + vi.stubGlobal('fetch', fetch) + fileUploadWorker() + const stream = new ReadableStream({ start(controller) { controller.close() } }) + scope.onmessage?.({ data: { url: '/stream', body: stream, headers: {} } } as MessageEvent) + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledOnce() + expect(posted.at(-1)).toEqual({ kind: 'complete', status: 204, body: '' }) + }) }) }) describe('file upload service', () => { + it('keeps the Host half inert', () => { + expect(() => { applyHost() }).not.toThrow() + }) + it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { vi.stubGlobal('location', { origin: 'https://preview.test' }) const fetch = vi.fn((_url: URL, _init?: RequestInit) => @@ -246,10 +303,14 @@ describe('file upload service', () => { url: 'https://harness.test/api/upload', body: blob, headers: {}, }) worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent) worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent) await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) - expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 }) + expect(progress.mock.calls).toEqual([ + [{ loaded: 4, total: 5 }], + [{ loaded: 6 }], + ]) expect(worker.terminate).toHaveBeenCalledOnce() await fiber.dispose() }) From 8fc9a11c5e473b338062702aac304891a4ed909c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:10:24 +0800 Subject: [PATCH 16/37] refactor(attachment): isolate file upload service --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 4 +- .../2026-08-26-generic-file-upload.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 65 +++- docs/subsystems/attachment.zh.md | 65 +++- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 9 - docs/subsystems/session.zh.md | 9 - packages/api/remotes/package.json | 1 + packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/tsconfig.client.json | 3 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 6 +- packages/api/session-controller/README.zh.md | 6 +- packages/api/session-controller/package.json | 4 +- .../src/client/contract/session.ts | 17 +- .../src/client/sessions/session.ts | 96 +----- .../api/session-controller/src/commands.ts | 154 +-------- .../src/file-upload-http.ts | 116 ------- .../src/file-upload-path.ts | 2 - packages/api/session-controller/src/index.ts | 29 +- packages/api/session-controller/src/types.ts | 23 +- .../tests/commands-upload-file.host.spec.ts | 198 ++++++----- .../tests/controller.host.spec.ts | 20 -- .../tests/fake-api.client.ts | 8 - .../tests/file-upload-http.host.spec.ts | 78 ++--- .../tests/session.client.spec.ts | 184 ---------- .../session-controller/tests/test-remote.ts | 8 + .../session-controller/tsconfig.client.json | 3 +- .../api/session-controller/tsconfig.host.json | 3 +- packages/client/file-upload/README.i18n.yaml | 4 +- packages/client/file-upload/README.md | 20 +- packages/client/file-upload/README.zh.md | 20 +- packages/client/file-upload/package.json | 47 ++- .../client/file-upload/src/client/contract.ts | 40 ++- .../client/file-upload/src/client/index.ts | 9 +- .../client/file-upload/src/client/runtime.ts | 126 ++++++- packages/client/file-upload/src/index.ts | 316 +++++++++++++++++- packages/client/file-upload/src/protocol.ts | 2 + packages/client/file-upload/src/types.ts | 22 ++ .../tests/file-upload.client.spec.ts | 152 ++++++++- .../client/file-upload/tsconfig.client.json | 22 ++ .../client/file-upload/tsconfig.host.json | 20 ++ packages/client/file-upload/tsconfig.json | 14 +- .../client/conversation-nodes/compaction.ts | 3 +- packages/client/ui-conversation/package.json | 2 + .../ui-conversation/src/client/apply.ts | 15 +- .../src/client/contract/slots.ts | 2 +- .../ui-conversation/src/client/service.ts | 26 +- .../tests/apply-inject.client.spec.tsx | 34 +- .../conversation-registry.client.spec.ts | 1 - .../service-orchestration.client.spec.ts | 57 ++-- .../webworker-runtime/tsconfig.json | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 68 +++- .../client-runtime/src/sessions.ts | 8 - .../tests/runtime.client.spec.tsx | 1 - pnpm-lock.yaml | 33 ++ scripts/gen-cordis-catalog.ts | 7 +- tsconfig.client.json | 2 +- tsconfig.host.json | 1 + 61 files changed, 1246 insertions(+), 965 deletions(-) delete mode 100644 packages/api/session-controller/src/file-upload-http.ts delete mode 100644 packages/api/session-controller/src/file-upload-path.ts create mode 100644 packages/client/file-upload/src/protocol.ts create mode 100644 packages/client/file-upload/src/types.ts create mode 100644 packages/client/file-upload/tsconfig.client.json create mode 100644 packages/client/file-upload/tsconfig.host.json diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 39f6330118..7511572021 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: bc8eb9783096e5e7763969630b57419a64c0cb9f -2026-08-26-generic-file-upload.zh.md: dbc392d700a84a77d321e5637be34da8c113a331 +2026-08-26-generic-file-upload.md: b1beeb5afd50a1ff03ba583567202608260e8ebe +2026-08-26-generic-file-upload.zh.md: 0407cb76967c27831dfd8401da422d448290637c diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index bc8eb97830..b1beeb5afd 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -12,7 +12,7 @@ The command plane had already established a whole-envelope rule for images: a co ## Decision -Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent Client `fileUpload` Cordis service accepts `Blob` and one-shot `ReadableStream` bodies; a Session resolves it from its Agent scope instead of carrying an upload object through `ClientSessions`, `SessionManager`, and constructor options. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. +Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts an Agent Context together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it obtains the wire identity from Typert's registered `agent` Context adapter and assembles the raw route or scoped Remote request. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, byte storage, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. The Host validates every file receipt before persisting any image, rejects upload completion after the original Agent is disposed, and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. @@ -28,7 +28,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- - **Store objective attachments in the goal domain.** Rejected: one ordinary logged user message gives later goal rounds the same history without widening the goal schema or duplicating attachments into round prompts. - **Consume attachments on any command success.** Rejected: `/goal pause` or `/plan off` would clear unrelated cards. Consumption follows the producer's explicit success; grammar forms that cannot use attachments return an error. - **Enforce declarations only in the client.** Rejected: direct RPC callers can bypass the composer. `CommandRuntime` enforces declaration, store availability, same-Session file-receipt resolution, image base64 admission, and image limits before entering the handler. -- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. An independent Cordis service is inherited by the Session's existing Agent scope and lets browser and Worker-hosted pages replace the carrier without widening Connection. +- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. The independent upload service accepts the existing Agent Context directly and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index dbc392d700..0407cb7696 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的 Client `fileUpload` Cordis 服务接受 `Blob` 和只能使用一次的 `ReadableStream` 请求体;Session 从自己的 Agent scope 取得该服务,无需让 `ClientSessions`、`SessionManager` 和构造参数逐层携带上传对象。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 +文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收 Agent Context 与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它通过 Typert 已注册的 `agent` Context adapter 取得 wire identity,并组装原始路由或按 scope 寻址的 Remote 请求。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、字节存储、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Host 会在持久化任何图片前验证所有文件凭证,原 Agent 已销毁时拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 @@ -28,7 +28,7 @@ Status: implemented - **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。 - **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。 - **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。 -- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立 Cordis 服务由 Session 现有的 Agent scope 继承,并允许普通浏览器与 Worker-hosted 页面替换载体,而无需扩大 Connection。 +- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收现有 Agent Context,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。 ## 后果 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index dd8f010d89..a60f9ab92d 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 8c9199bc246fa8866cb5a2bba0edcd187bf779ac -attachment.zh.md: d651d095e1157601086a94f7f4c2116c3bb5dee3 +attachment.md: 19c659508936552a449b7b4ee9a085f015ed8c14 +attachment.zh.md: 9942c7a9d2d7b37e10c946c10297cbdada0936c2 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 8c9199bc24..19c6595089 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -1,10 +1,10 @@ -# Durable Image Attachments +# Durable Attachments English | [中文](attachment.zh.md) -The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. +The attachment seam separates binary image and generic-file ownership from the session log. A producer gives bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible attachment blocks contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. The independent [`ctx.fileUploads`](#ctxfileuploads--fileuploads) service binds browser file transfers and staged receipts to the receiving Agent. -Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. +Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Browser generic files become durable before they receive a staged prompt receipt. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) @@ -232,4 +232,63 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) + + + +### `ctx.fileUploads` — `FileUploads` + +Host service owning upload storage and Agent-scoped staged receipts. + +```ts cordis-catalog +/** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ +registerAgentResolver(resolve: AgentResolver): () => void + +/** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ +@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise + +/** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ +async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise + +/** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ +resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined + +/** + * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns rollback restoring every prior binding. + */ +bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void + +/** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ +retirePrompt(agent: Agent, requestId: string): void +``` + +Types: [Agent](core.md) · [SessionId](core.md) + +Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index d651d095e1..9942c7a9d2 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -1,10 +1,10 @@ -# 持久图片附件 +# 持久附件 [English](attachment.md) | 中文 -附件 seam 将二进制图片的所有权与会话日志分离。生产方把经过校验的编码字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的 `ImageBlock` 包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。 +附件 seam 将二进制图片和通用文件的所有权与会话日志分离。生产方把字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的附件块包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。独立的 [`ctx.fileUploads`](#ctxfileuploads--fileuploads) 服务把浏览器文件传输与暂存凭证绑定到接收方 Agent。 -未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 +未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。浏览器通用文件取得暂存 prompt 凭证前会完成持久化。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 来源:[`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) @@ -232,4 +232,63 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) + + + +### `ctx.fileUploads` — `FileUploads` + +Host service owning upload storage and Agent-scoped staged receipts. + +```ts cordis-catalog +/** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ +registerAgentResolver(resolve: AgentResolver): () => void + +/** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ +@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise + +/** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ +async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise + +/** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ +resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined + +/** + * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns rollback restoring every prior binding. + */ +bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void + +/** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ +retirePrompt(agent: Agent, requestId: string): void +``` + +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) + +Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 5f38070a89..f9653cecea 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 docs/subsystems/session.md -session.md: a22c8744262a785423f851fdc843cea44a2eb164 -session.zh.md: 61816c98e40f30186174703d92ea7473ce4f5c07 +session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 +session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index a22c874426..48a80ccb92 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -764,15 +764,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise -/** - * Persist one encoded file upload verbatim and stage it for a later prompt - * on the same Session. - * @param request - Session identity, base64 payload, and optional display name. - * @param signal - caller cancellation before storage begins. - * @returns an opaque per-upload receipt and the durable file reference. - */ -@Remote('uploadFile') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise - /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 61816c98e4..df5cfd631a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -768,15 +768,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise -/** - * Persist one encoded file upload verbatim and stage it for a later prompt - * on the same Session. - * @param request - Session identity, base64 payload, and optional display name. - * @param signal - caller cancellation before storage begins. - * @returns an opaque per-upload receipt and the durable file reference. - */ -@Remote('uploadFile') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise - /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index ad2b50137d..51a7fe0277 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -78,6 +78,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index e7fc7757e9..cc917458dc 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' @@ -24,6 +25,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-client-file-upload/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' @@ -145,7 +147,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { try { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, - pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 56f664d499..da348fa6c2 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -20,6 +20,9 @@ { "path": "../../client/connection/tsconfig.client.json" }, + { + "path": "../../client/file-upload/tsconfig.host.json" + }, { "path": "../../credentials/credentials" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 12587c382c..555f274fbc 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: ab4001052306be1b052d1ca6a0e8ff8ddeffd459 -README.zh.md: 5100959e826e1099007a85d34b345a7d7d8481e8 +README.md: effcf3fe21b8091ee0eb012dd80dfe2f4e75d394 +README.zh.md: 6f1a260f60377c6a8079ffbde757fe4dbada9337 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ab40010523..effcf3fe21 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, Agent-scoped file references, and generic file upload. Use it through API Gateway when a Client needs operations addressed by a Session. +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. ## Table of Contents @@ -25,9 +25,9 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, file-reference, and file-upload operations may resolve or resume an ordinary Session. A generated Remote accepts base64 file bytes for non-browser carriers, while an authenticated exact Fetch route streams raw chunks from the browser background-upload carrier without entering Connection's buffered JSON path. The streaming path hashes and writes each chunk with backpressure, publishes the completed object atomically, and removes its staging file on cancellation or failure. Both paths store the file verbatim and stage it in this process for the same exact Agent; completion after that Agent is disposed stores no receipt. A later prompt or attachment-accepting command cites the opaque per-upload receipt. Both admission paths validate every same-Session receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message; prompt receipts retire when that `rpcId` becomes a user event or its queued occurrence is removed, while command-only receipts remain until Session disposal. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and validates every same-Agent receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. A Client Session resolves the independent [`fileUpload`](../../client/file-upload/README.md) service from its Agent scope for `Blob` and one-shot `ReadableStream` bodies. A fixture or bare Session keeps the generated Remote fallback for `Blob` and exact-byte inputs; a stream requires the background service because it cannot be replayed through the base64 Remote. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 5100959e82..6f1a260f60 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现、面向 Agent 的文件引用和通用文件上传。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现和面向 Agent 的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 ## 目录 @@ -25,9 +25,9 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、文件引用和文件上传操作可以解析或恢复普通 Session。生成 Remote 为非浏览器载体接收 base64 文件字节,经过认证的精确 Fetch 路由则从浏览器后台上传载体流式接收原始分块,不进入 Connection 的缓冲 JSON 路径。流式路径以背压逐块计算摘要和写入,完整接收后以原子方式发布对象,并在取消或失败时移除暂存文件。两条路径都按字节原样存储文件,并在本进程内按同一个准确 Agent 暂存;如果保存完成时该 Agent 已销毁,就不发布凭证。之后的 prompt 或接受附件的命令引用每次上传独有的不透明凭证。两条准入路径都先验证所有凭证属于同一 Session,再持久化图片。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息;prompt 凭证在同一 `rpcId` 进入 user event 或对应 queue occurrence 被移除后退休,只被命令使用的凭证则保留到 Session 销毁。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,并在持久化图片前验证每个凭证属于同一个准确 Agent。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Session 从自己的 Agent scope 取得独立的 [`fileUpload`](../../client/file-upload/README.zh.md) 服务,用于 `Blob` 与只能使用一次的 `ReadableStream` 请求体。fixture 或未绑定的 Session 会让 `Blob` 与精确字节输入继续使用生成的 Remote;stream 无法通过 base64 Remote 重放,因此必须有后台服务。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index f96d03586f..6fbc1a5199 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -47,8 +47,7 @@ "@deepseek-ai/dsh-api-gateway/client" ], "inject": [ - "@deepseek-ai/dsh-api-gateway", - "@deepseek-ai/dsh-client-file-upload" + "@deepseek-ai/dsh-api-gateway" ], "platform": "web" } @@ -82,6 +81,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 653ed05442..35f0ffd2bf 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -12,7 +12,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { FileUploadReceiptId, PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' +import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts' /** @@ -89,21 +89,6 @@ export interface ISession { signal?: AbortSignal, requestId?: SessionRequestId, ): Promise> - /** - * Persist one browser file verbatim and stage it for a later prompt on this - * session. The returned opaque receipt is what a prompt file part cites. - * @param data - browser Blob, exact file bytes, or a one-shot byte stream. - * @param name - optional display name; the host sanitizes the stored leaf name. - * @param signal - optional cancellation for the active upload. - * @param onProgress - optional byte-progress observer for background Blob or stream uploads. - * @returns the staged-upload receipt and durable file reference, or the business error. - */ - uploadFile( - data: Blob | Uint8Array | ReadableStream, - name?: string, - signal?: AbortSignal, - onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, - ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index cb92cff454..c79c034d9b 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -1,8 +1,7 @@ // Sessions remain resident after creation so their open Remote sources keep running off-screen. import type { Context } from '@deepseek-ai/cordis' -import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { FileUploadProgress } from '@deepseek-ai/dsh-client-file-upload/client' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -17,9 +16,7 @@ import type { SessionProjectionBaseline, SessionQueuedItem, SessionRequestId, - SessionUploadFileValue, } from '../../types.ts' -import { SESSION_FILE_UPLOAD_PATH } from '../../file-upload-path.ts' import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' @@ -295,57 +292,6 @@ export class Session implements SessionFace { return result } - /** - * Persist one browser file verbatim and stage it for a later prompt on this - * session (ordinary sessions only; subagent conversations refuse). - * @param data - exact bytes, a browser Blob, or a one-shot byte stream. - * @param name - optional display name; the host sanitizes the stored leaf name. - * @param signal - optional cancellation for the active upload. - * @param onProgress - optional byte-progress observer for background bodies. - * @returns the staged-upload receipt and durable file reference, or the business error. - */ - async uploadFile( - data: Blob | Uint8Array | ReadableStream, - name?: string, - signal?: AbortSignal, - onProgress?: (progress: FileUploadProgress) => void, - ): Promise> { - if (this.address !== undefined) { - return { - ok: false, - error: new RemoteError( - 'subagent/attachment-invalid', - 'subagent conversations do not accept file uploads', - { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, - ), - } - } - if (!(data instanceof Uint8Array) && this.actx?.fileUpload.available === true) { - const query = new URLSearchParams({ sessionId: this.sessionId }) - if (name !== undefined) query.set('name', name) - const response = await this.actx.fileUpload.post({ - path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`, - body: data, - headers: { 'content-type': 'application/octet-stream' }, - ...(signal === undefined ? {} : { signal }), - ...(onProgress === undefined ? {} : { onProgress }), - }) - if (response.status !== 200) { - throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) - } - return parseFileUploadResult(response.body) - } - if (!(data instanceof Uint8Array) && !(data instanceof Blob)) { - throw new Error('stream file upload requires a bound Client file-upload service') - } - const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) - return this.remote.session.uploadFile({ - sessionId: this.sessionId, - data: bytesToBase64(bytes), - ...(name === undefined ? {} : { name }), - }, signal) - } - /** * Resolve one image referenced by this session into browser-consumable bytes. * @param attachmentId - opaque id found in the folded session log. @@ -831,46 +777,6 @@ export class Session implements SessionFace { } } -function parseFileUploadResult(body: string): RemoteResult { - const value = JSON.parse(body) as unknown - if (!isRecord(value) || typeof value.ok !== 'boolean') { - throw new TypeError('file upload transport returned an invalid result') - } - if (!value.ok) { - const error = value.error - if (!isRecord(error) || typeof error.code !== 'string' - || typeof error.message !== 'string' || !isRecord(error.details)) { - throw new TypeError('file upload transport returned an invalid failure') - } - return { - ok: false, - error: new RemoteError(error.code as never, error.message, error.details as never), - } - } - const result = value.value - const file = isRecord(result) ? result.file : undefined - if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) - || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' - || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { - throw new TypeError('file upload transport returned an invalid receipt') - } - return { - ok: true, - value: { - receiptId: result.receiptId as SessionUploadFileValue['receiptId'], - file: { - attachmentId: file.attachmentId as SessionUploadFileValue['file']['attachmentId'], - name: file.name, - bytes: file.bytes, - }, - }, - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - /** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ function scheduleFrame(fn: () => void): void { if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 99d30d081c..01afde33e3 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,9 +4,10 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitEncodedFile, admitPromptContent } from '@deepseek-ai/dsh-attachment' -import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' +import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' @@ -37,7 +38,6 @@ import type { SessionCreateValue, SessionForkRequest, SessionForkValue, - FileUploadReceiptId, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -46,8 +46,6 @@ import type { SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, - SessionUploadFileRequest, - SessionUploadFileValue, SessionRequestId, } from './types.ts' @@ -57,21 +55,8 @@ interface SessionReadState { readonly events: readonly SessionEvent[] } -interface StagedFileUpload { - readonly file: FileAttachmentRef - /** Prompt that accepted this receipt; absent until successful admission. */ - requestId?: SessionRequestId -} - /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { - /** - * Staged file uploads awaiting a prompt, keyed by Session. Entries are the - * prompt-time authority for file references: a prompt may only cite a file - * previously uploaded for the same Session in this process. - */ - private readonly stagedFiles = new Map>() - /** * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. * @param agents - sole owner of create, resume, and Session-local model selection. @@ -81,115 +66,7 @@ export class SessionCommandController { private readonly ctx: Context, private readonly agents: ApiSessionAgentController, private readonly defaultCwd: string, - ) { - ctx.inject(['commands'], (commandCtx) => { - const resolve: CommandFileReceiptResolver = (agent, receiptId) => - this.resolveStagedFile(agent.id, receiptId as FileUploadReceiptId) - commandCtx.effect( - () => commandCtx.commands.registerFileReceiptResolver(resolve), - 'session-controller: command file receipt resolver', - ) - }) - } - - /** - * Persist one browser file upload verbatim and stage it for later prompts. - * @param request - Session identity, base64 payload, and optional display name. - * @returns an opaque per-upload receipt and the durable file reference. - */ - async uploadFile(request: SessionUploadFileRequest): Promise { - const agent = await this.resolveAgent(request.sessionId) - return this.commitFileUpload(agent, async () => admitEncodedFile(this.ctx.attachments, { - data: request.data, - ...(request.name === undefined ? {} : { name: request.name }), - })) - } - - /** - * Persist raw upload chunks without collecting the complete file in memory. - * @param request - Session identity, ordered exact bytes, cancellation, and optional display name. - * @returns an opaque per-upload receipt and the durable file reference. - */ - async uploadFileStream(request: { - readonly sessionId: SessionId - readonly data: AsyncIterable - readonly signal?: AbortSignal - readonly name?: string - }): Promise { - const agent = await this.resolveAgent(request.sessionId) - return this.commitFileUpload(agent, async () => this.ctx.attachments.saveFileStream({ - data: request.data, - ...(request.signal === undefined ? {} : { signal: request.signal }), - ...(request.name === undefined ? {} : { name: request.name }), - })) - } - - private async commitFileUpload( - agent: Agent, - save: () => Promise, - ): Promise { - let file: FileAttachmentRef - try { - file = await save() - } catch (error) { - if (error instanceof AttachmentError) { - throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) - } - throw new RemoteError( - 'gateway/internal', - `failed to store file upload: ${String(error)}`, - {}, - { cause: error }, - ) - } - if (this.ctx.agents.get(agent.id) !== agent) { - throw new RemoteError( - 'session/not-found', - `session "${agent.id}" was disposed before its file upload completed`, - { sessionId: agent.id }, - ) - } - let staged = this.stagedFiles.get(agent.id) - if (staged === undefined) { - staged = new Map() - this.stagedFiles.set(agent.id, staged) - } - const receiptId = randomUUID() as FileUploadReceiptId - staged.set(receiptId, { file }) - return { receiptId, file } - } - - /** - * Resolve one staged upload for the same Session without exposing the receipt table. - * @param sessionId - receiving Session identity. - * @param receiptId - Host-minted upload receipt. - * @returns the durable file reference, or `undefined` when the receipt is absent or belongs elsewhere. - */ - resolveStagedFile(sessionId: SessionId, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { - return this.stagedFiles.get(sessionId)?.get(receiptId)?.file - } - - /** - * Retire file receipts only after their accepted prompt becomes observable. - * @param sessionId - Session whose log emitted the prompt. - * @param requestId - browser prompt identity echoed by the event. - */ - retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void { - const staged = this.stagedFiles.get(sessionId) - if (staged === undefined) return - for (const [receiptId, upload] of staged) { - if (upload.requestId === requestId) staged.delete(receiptId) - } - if (staged.size === 0) this.stagedFiles.delete(sessionId) - } - - /** - * Drop one Session's staged uploads (the stored objects remain durable). - * @param sessionId - Session leaving the live registry. - */ - releaseStagedFiles(sessionId: SessionId): void { - this.stagedFiles.delete(sessionId) - } + ) {} /** * Create or idempotently adopt one ordinary Session. @@ -451,11 +328,10 @@ export class SessionCommandController { ) } } - const staged = this.stagedFiles.get(request.sessionId) const durable = await durablePromptContent( this.ctx, request.content, - receiptId => staged?.get(receiptId)?.file, + receiptId => this.ctx.fileUploads.resolve(agent, receiptId), ) const message: UserMessage = createUserMessage({ content: durable.content, source }) if (this.ctx.agents.get(agent.id) !== agent) { @@ -465,26 +341,12 @@ export class SessionCommandController { { sessionId: agent.id }, ) } - const bound = durable.receiptIds.map((receiptId) => { - const upload = staged?.get(receiptId) - if (upload === undefined) { - throw new RemoteError( - 'session/attachment-invalid', - 'File was not uploaded for this session.', - { reason: 'FILE_NOT_STAGED' }, - ) - } - return { upload, previous: upload.requestId } - }) - for (const { upload } of bound) upload.requestId = request.requestId + const rollback = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) try { if (request.mode === 'steer') agent.steer(message) else agent.followup(message) } catch (error) { - for (const { upload, previous } of bound) { - if (previous === undefined) delete upload.requestId - else upload.requestId = previous - } + rollback() throw error } } catch (error) { @@ -583,7 +445,7 @@ export class SessionCommandController { if (request.action.kind === 'remove') { const source = message.source if (source.kind === 'user' && 'rpcId' in source) { - this.retireObservedPrompt(request.sessionId, source.rpcId) + this.ctx.fileUploads.retirePrompt(agent, source.rpcId) } } if (request.action.kind === 'steer') agent.steer(message) diff --git a/packages/api/session-controller/src/file-upload-http.ts b/packages/api/session-controller/src/file-upload-http.ts deleted file mode 100644 index 7a4a210e65..0000000000 --- a/packages/api/session-controller/src/file-upload-http.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** Raw Fetch file intake used by the browser background-upload carrier. */ - -import type { Context } from '@deepseek-ai/cordis' -import { SessionId } from '@deepseek-ai/dsh-session' -import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' -import { SESSION_FILE_UPLOAD_PATH } from './file-upload-path.ts' -import type { SessionUploadFileValue } from './types.ts' -import type { SessionCommandController } from './commands.ts' - -interface FileUploadConnection { - readonly fetch: { - register(route: { - readonly path: string - readonly methods: readonly ['POST'] - readonly requestBody: 'streaming' - readonly fetch: (request: Request) => Promise - }): () => Promise - } -} - -type FileUploadResult = - | { readonly ok: true; readonly value: SessionUploadFileValue } - | { - readonly ok: false - readonly error: { readonly code: string; readonly message: string; readonly details: object } - } - -/** - * Install the authenticated raw-byte route on Connection's shared Fetch registry. - * @param ctx - Host context that provides Connection. - * @param commands - Session command owner that validates and stages stored bytes. - */ -export function registerSessionFileUploadHttp(ctx: Context, commands: SessionCommandController): void { - ctx.inject(['connection'], (connectionCtx) => { - const connection = connectionCtx.get('connection') as FileUploadConnection - connection.fetch.register({ - path: SESSION_FILE_UPLOAD_PATH, - methods: ['POST'], - requestBody: 'streaming', - fetch: request => handleSessionFileUploadHttp(commands, request), - }) - }) -} - -/** - * Handle one authenticated raw-byte upload after the physical carrier applies - * its trust policy. - * @param commands - Session command owner that validates and stages stored bytes. - * @param request - Fetch request carrying the raw file body. - * @returns JSON receipt or a precise validation response. - */ -export async function handleSessionFileUploadHttp( - commands: SessionCommandController, - request: Request, -): Promise { - if (request.method !== 'POST') { - return new Response(null, { status: 405, headers: { allow: 'POST' } }) - } - const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - if (mediaType !== 'application/octet-stream') { - return new Response('content type must be application/octet-stream', { status: 415 }) - } - const url = new URL(request.url) - const sessionId = url.searchParams.get('sessionId') - if (sessionId === null || sessionId === '') { - return new Response('sessionId is required', { status: 400 }) - } - const name = url.searchParams.get('name') ?? undefined - let result: FileUploadResult - try { - result = { - ok: true, - value: await commands.uploadFileStream({ - sessionId: SessionId(sessionId), - data: requestBodyChunks(request.body), - signal: request.signal, - ...(name === undefined ? {} : { name }), - }), - } - } catch (error) { - const failure = remoteErrorOf(error) - result = { - ok: false, - error: failure !== undefined - ? { code: failure.code, message: failure.message, details: failure.details } - : { - code: 'gateway/internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, - } - } - return new Response(JSON.stringify(result), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - }, - }) -} - -async function* requestBodyChunks( - body: ReadableStream | null, -): AsyncIterable { - if (body === null) return - const reader = body.getReader() - try { - while (true) { - const chunk = await reader.read() - if (chunk.done) return - yield chunk.value - } - } finally { - reader.releaseLock() - } -} diff --git a/packages/api/session-controller/src/file-upload-path.ts b/packages/api/session-controller/src/file-upload-path.ts deleted file mode 100644 index 8c5c1f0b8e..0000000000 --- a/packages/api/session-controller/src/file-upload-path.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Authenticated raw-byte upload route shared by Host and Client faces. */ -export const SESSION_FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 2255200402..d7efae3f2f 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' @@ -17,7 +18,6 @@ import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' -import { registerSessionFileUploadHttp } from './file-upload-http.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, @@ -55,8 +55,6 @@ import type { SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, - SessionUploadFileRequest, - SessionUploadFileValue, } from './types.ts' export type * from './types.ts' @@ -95,6 +93,7 @@ export class SessionController extends TypertRemoteService { 'agentDefaultModel', 'agents', 'attachments', + 'fileUploads', 'llm', 'sessions', 'sessionProjections', @@ -128,7 +127,11 @@ export class SessionController extends TypertRemoteService { installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) - registerSessionFileUploadHttp(ctx, this.commands) + ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { + const result = await this.agents.resolveAgent(sessionId) + if ('error' in result) throw result.error + return result.agent + }), 'session-controller: file-upload Agent resolver') this.controlState = new SessionControlController(ctx) // Registered before history so reverse-order teardown closes every // follower before waiting for already-admitted promotions. @@ -150,7 +153,6 @@ export class SessionController extends TypertRemoteService { ctx.emit('api-session/added', this.listState.summaryFor(session)) }) ctx.on('session/disposed', (session) => { - this.commands.releaseStagedFiles(session.id) ctx.emit('api-session/removed', session.id) }) ctx.on('agent/status', ({ agent, status }) => { @@ -160,10 +162,6 @@ export class SessionController extends TypertRemoteService { ctx.emit('api-session/error', agent.id, errorChain(error)) }) ctx.on('session/event', (session, event) => { - if (event.type === 'user/message' && event.data.source.kind === 'user' - && 'rpcId' in event.data.source) { - this.commands.retireObservedPrompt(session.id, event.data.source.rpcId) - } if (event.type === 'request/header') { const agent = ctx.agents.get(session.id) if (agent?.session === session) this.agents.consumeSelection( @@ -356,19 +354,6 @@ export class SessionController extends TypertRemoteService { return this.commands.attachment(request) } - /** - * Persist one encoded file upload verbatim and stage it for a later prompt - * on the same Session. - * @param request - Session identity, base64 payload, and optional display name. - * @param signal - caller cancellation before storage begins. - * @returns an opaque per-upload receipt and the durable file reference. - */ - @Remote('uploadFile') - uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise { - signal.throwIfAborted() - return this.commands.uploadFile(request) - } - /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index ba7056f298..606ec569b9 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,7 +1,7 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ import type { - AttachmentIdType, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, + AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, } from '@deepseek-ai/dsh-attachment' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -81,7 +81,7 @@ export type PromptContentPart = readonly data: string readonly name?: string } - | { readonly type: 'file'; readonly receiptId: FileUploadReceiptId } + | { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> } /** Complete model selection for one Session. */ export interface ModelSelection { @@ -318,22 +318,6 @@ export interface SessionPromptValue { readonly accepted: true } -/** One base64 file upload staged for a later prompt on the same Session. */ -export interface SessionUploadFileRequest { - readonly sessionId: SessionId - /** Canonical base64 encoding of the exact file bytes. */ - readonly data: string - /** Optional display name; the Host sanitizes it into the stored leaf name. */ - readonly name?: string -} - -/** Durable receipt for one staged file upload. */ -export interface SessionUploadFileValue { - /** Per-upload authority consumed by a later prompt on the same Session. */ - readonly receiptId: FileUploadReceiptId - readonly file: FileAttachmentRef -} - /** Durable image read request. */ export interface SessionAttachmentRequest { readonly sessionId: SessionId @@ -382,9 +366,6 @@ export interface SessionOpenWorkspacePathValue { /** Client-minted prompt identity used to reconcile optimistic and durable messages. */ export type SessionRequestId = Branded<'session-request-id'> -/** Host-minted authority for one staged file upload on one Session. */ -export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> - declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { /** Browser prompt correlation and optional Host-validated time zone. */ diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 73fa603aed..66e5e55302 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -9,16 +9,20 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import CommandRuntime from '@deepseek-ai/dsh-commands' +import { createScope } from '@deepseek-ai/dsh-scope' +import FileUploads from '@deepseek-ai/dsh-client-file-upload' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' import { describe, expect, it, vi } from 'vitest' import type { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' -import type { FileUploadReceiptId, SessionRequestId } from '../src/types.ts' +import type { SessionRequestId } from '../src/types.ts' const SESSION = SessionId('upload-session') -async function uploadHarness(): Promise<{ +async function uploadHarness(origin?: 'subagent'): Promise<{ ctx: Context controller: SessionCommandController + uploads: FileUploads agent: Agent followup: ReturnType saveFile: ReturnType @@ -30,7 +34,9 @@ async function uploadHarness(): Promise<{ await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandRuntime) - const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace' } }) + const session = ctx.sessions.create(SESSION, { + meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, + }) const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const followup = vi.fn() const agent = { @@ -38,11 +44,12 @@ async function uploadHarness(): Promise<{ session, inbox, status: 'idle', - ctx, + ctx: undefined, steer: vi.fn(), followup, cancel: vi.fn(), } as unknown as Agent + ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx const disposeAgent = ctx.agents.register(agent) const saveFile = vi.fn((input: SaveFileAttachment): Promise => Promise.resolve({ attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), @@ -61,6 +68,7 @@ async function uploadHarness(): Promise<{ const saveImages = vi.fn((): Promise => Promise.reject(new Error('fixture did not expect image persistence'))) ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never) + ctx.provide('connection', { fetch: { register: () => async () => {} } } as never) ctx.provide('llm', { listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), @@ -74,9 +82,11 @@ async function uploadHarness(): Promise<{ selectionFor: () => selection, serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), } as unknown as ApiSessionAgentController + const uploads = new FileUploads(ctx) return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), + uploads, agent, followup, saveFile, @@ -97,14 +107,13 @@ function promptRequest(content: Parameters[0 describe('Session file uploads', () => { it('stages one verbatim upload and cites it from a later prompt as a file block', async () => { - const { ctx, controller, agent, followup, saveFile } = await uploadHarness() - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'notes.pdf' }) + const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal) expect(saveFile).toHaveBeenCalledTimes(1) expect(receipt.file.name).toBe('notes.pdf') expect(receipt.file.bytes).toBe(3) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) - expect(controller.resolveStagedFile(SessionId('other-session'), receipt.receiptId)).toBeUndefined() - expect(controller.resolveStagedFile(SESSION, 'missing' as FileUploadReceiptId)).toBeUndefined() + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined() const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) ctx.commands.register({ name: 'files', description: 'Use staged files', input: { hint: '', attachments: true }, @@ -132,9 +141,9 @@ describe('Session file uploads', () => { }) it('stages a bounded byte stream and forwards cancellation to storage', async () => { - const { controller, followup, saveFileStream } = await uploadHarness() + const { controller, uploads, followup, saveFileStream } = await uploadHarness() const abort = new AbortController() - const receipt = await controller.uploadFileStream({ + const receipt = await uploads.uploadStream({ sessionId: SESSION, data: (async function* (): AsyncIterable { yield Uint8Array.of(1, 2) @@ -155,13 +164,13 @@ describe('Session file uploads', () => { }) it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => { - const { controller, saveFileStream } = await uploadHarness() + const { uploads, saveFileStream } = await uploadHarness() const stream = (async function* (): AsyncIterable {})() - await expect(controller.uploadFileStream({ sessionId: SESSION, data: stream })) + await expect(uploads.uploadStream({ sessionId: SESSION, data: stream })) .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } }) expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream }) saveFileStream.mockRejectedValueOnce('disk offline') - await expect(controller.uploadFileStream({ + await expect(uploads.uploadStream({ sessionId: SESSION, data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), })) @@ -170,26 +179,6 @@ describe('Session file uploads', () => { }) }) - it('unregisters its command receipt resolver with the owning plugin fiber', async () => { - const ctx = new Context() - const commands = ctx.plugin(CommandRuntime) - await commands.await() - const agents = {} as ApiSessionAgentController - const plugin = { - apply(ownerCtx: Context) { - new SessionCommandController(ownerCtx, agents, '/workspace') - }, - } - - const first = ctx.plugin(plugin) - await first.await() - await first.dispose() - const replacement = ctx.plugin(plugin) - await replacement.await() - await replacement.dispose() - await commands.dispose() - }) - it('rejects a prompt citing a file that was never staged for the session', async () => { const { controller, followup, saveImages } = await uploadHarness() await expect(controller.prompt(promptRequest([ @@ -201,10 +190,10 @@ describe('Session file uploads', () => { }) it('publishes no receipt when its exact Agent is disposed during storage', async () => { - const { controller, saveFile, disposeAgent } = await uploadHarness() + const { uploads, agent, saveFile, disposeAgent } = await uploadHarness() const saved = Promise.withResolvers() saveFile.mockReturnValueOnce(saved.promise) - const uploading = controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'late.bin' }) + const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal) await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() }) disposeAgent() saved.resolve({ @@ -213,16 +202,84 @@ describe('Session file uploads', () => { await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' }) }) + it('resolves a cold ordinary Agent and releases the resolver registration', async () => { + const { ctx, uploads, agent, disposeAgent } = await uploadHarness() + disposeAgent() + const resolveAgent = vi.fn(async () => { + ctx.agents.register(agent) + return agent + }) + const disposeResolver = uploads.registerAgentResolver(resolveAgent) + expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered') + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + })).resolves.toMatchObject({ file: { bytes: 1 } }) + expect(resolveAgent).toHaveBeenCalledWith(SESSION) + disposeResolver() + const replacement = vi.fn(async () => agent) + const disposeReplacement = uploads.registerAgentResolver(replacement) + expect(disposeReplacement).toBeTypeOf('function') + disposeReplacement() + }) + + it('rejects subagent uploads and access outside the receiving Agent scope', async () => { + const child = await uploadHarness('subagent') + await expect(child.uploads.upload( + child.agent, + { data: 'AAAA' }, + new AbortController().signal, + )).rejects.toMatchObject({ + code: 'subagent/attachment-invalid', + details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + }) + expect(child.saveFile).not.toHaveBeenCalled() + + const ordinary = await uploadHarness() + const receipt = await ordinary.uploads.upload( + ordinary.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent + expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId)) + .toThrow('operation requires the Agent\'s own scope') + }) + it('retires accepted receipts after their rpcId becomes observable', async () => { - const { controller } = await uploadHarness() - controller.retireObservedPrompt(SESSION, 'not-staged' as SessionRequestId) - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + const { controller, uploads, agent } = await uploadHarness() + uploads.retirePrompt(agent, 'not-staged' as SessionRequestId) + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) - controller.retireObservedPrompt(SESSION, 'other-request' as SessionRequestId) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) - controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined() + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'other-request' as SessionRequestId) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('retires observed prompt receipts and drops staged state with the Session', async () => { + const first = await uploadHarness() + const observed = await first.uploads.upload( + first.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + first.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'observed' }], + source: { kind: 'user', rpcId: 'req-1' as SessionRequestId }, + }), { surfaceOp: 'append' }) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined() + + const second = await uploadHarness() + const abandoned = await second.uploads.upload( + second.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + second.ctx.emit('session/disposed', second.agent.session) + expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined() }) it('deduplicates a retried rpcId already present in the Agent inbox', async () => { @@ -267,8 +324,8 @@ describe('Session file uploads', () => { }) it('rejects when a previously bound receipt retires during image admission', async () => { - const { controller, saveImages } = await uploadHarness() - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + const { controller, uploads, agent, saveImages } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) const admitted = Promise.withResolvers() saveImages.mockReturnValueOnce(admitted.promise) @@ -280,7 +337,7 @@ describe('Session file uploads', () => { requestId: 'req-2' as SessionRequestId, }) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) - controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) + uploads.retirePrompt(agent, 'req-1' as SessionRequestId) admitted.resolve([{ attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, }]) @@ -290,32 +347,32 @@ describe('Session file uploads', () => { }) it('keeps the prior receipt binding when a later prompt attempt fails', async () => { - const { controller, followup } = await uploadHarness() - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt({ ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), requestId: 'req-2' as SessionRequestId, })).rejects.toMatchObject({ code: 'session/agent-busy' }) - controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined() + uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) it('restores an unbound receipt after prompt delivery fails', async () => { - const { controller, followup } = await uploadHarness() - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) - controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) }) it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { - const { controller, agent, followup } = await uploadHarness() - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) const queued = followup.mock.calls[0]?.[0] as UserMessage agent.inbox.append('next-turn', queued) @@ -324,24 +381,13 @@ describe('Session file uploads', () => { itemId: queued.id, action: { kind: 'remove' }, })).toEqual({ accepted: true }) - expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined() - }) - - it('drops staged uploads with the session while the stored object stays durable', async () => { - const { controller, followup } = await uploadHarness() - const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }) - expect(receipt.file.name).toBe('file') - controller.releaseStagedFiles(SESSION) - await expect(controller.prompt(promptRequest([ - { type: 'file', receiptId: receipt.receiptId }, - ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) - expect(followup).not.toHaveBeenCalled() + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) it('keeps separate names for identical bytes uploaded more than once', async () => { - const { controller, followup } = await uploadHarness() - const first = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'first.txt' }) - const second = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'second.txt' }) + const { controller, uploads, agent, followup } = await uploadHarness() + const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal) + const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal) expect(first.file.attachmentId).toBe(second.file.attachmentId) expect(first.receiptId).not.toBe(second.receiptId) @@ -358,16 +404,16 @@ describe('Session file uploads', () => { }) it('maps a non-canonical payload to the wire attachment error', async () => { - const { controller, saveFile } = await uploadHarness() - await expect(controller.uploadFile({ sessionId: SESSION, data: 'not base64!!' })) + const { uploads, agent, saveFile } = await uploadHarness() + await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal)) .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } }) expect(saveFile).not.toHaveBeenCalled() }) it('maps an unexpected storage failure to the internal wire error', async () => { - const { controller, saveFile } = await uploadHarness() + const { uploads, agent, saveFile } = await uploadHarness() saveFile.mockRejectedValueOnce(new Error('disk unavailable')) - await expect(controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })) + await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)) .rejects.toMatchObject({ code: 'gateway/internal', message: 'failed to store file upload: Error: disk unavailable', diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 74f5f3a99b..5a8ffb783b 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -8,7 +8,6 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import SessionController from '../src/index.ts' import type { ApiSessionAgentController } from '../src/agent.ts' -import type { SessionRequestId } from '../src/types.ts' import { createSessionTestController, testSessionPersistence } from './test-remote.ts' const defaults = { @@ -44,21 +43,6 @@ describe('SessionController facade', () => { inspect, }) as never) const controller = createSessionTestController(ctx, defaults) - const uploadRequest = { sessionId, data: 'AAAA', name: 'notes.txt' } - const uploadValue = { - receiptId: 'receipt-1' as never, - file: { - attachmentId: `sha256:${'ab'.repeat(32)}` as never, - name: 'notes.txt', - bytes: 3, - }, - } - const uploadFile = vi.spyOn((controller as unknown as { - commands: { uploadFile(request: typeof uploadRequest): Promise } - }).commands, 'uploadFile').mockResolvedValue(uploadValue) - await expect(controller.uploadFile(uploadRequest, new AbortController().signal)) - .resolves.toBe(uploadValue) - expect(uploadFile).toHaveBeenCalledWith(uploadRequest) const status = vi.fn() const failure = vi.fn() const activity = vi.fn() @@ -85,9 +69,6 @@ describe('SessionController facade', () => { (controller as unknown as { agents: ApiSessionAgentController }).agents, 'consumeSelection', ) - const retireObservedPrompt = vi.spyOn((controller as unknown as { - commands: { retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void } - }).commands, 'retireObservedPrompt') await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) await expect(controller.inspect(sessionId)).resolves.toEqual({ @@ -109,7 +90,6 @@ describe('SessionController facade', () => { expect(status).toHaveBeenCalledWith(sessionId, true) expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) - expect(retireObservedPrompt).toHaveBeenCalledWith(sessionId, 'controller-rpc') session.append('request/header', { header: { config: { provider: 'fixture', model: 'fixture-model' } }, reason: 'initial', diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 58c1beec7c..e3ea48f783 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -143,13 +143,6 @@ export class FakeApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUploadFile: (payload: unknown) => Promise> = () => Promise.resolve(ok({ - receiptId: 'r' as never, - file: { attachmentId: 'f' as never, name: 'file', bytes: 1 }, - })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onOpenWorkspacePath: (payload: unknown) => Promise> = @@ -232,7 +225,6 @@ export class FakeApiClient { fork: payload => this.record('session.fork', payload, this.onFork(payload)), prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), - uploadFile: payload => this.record('session.uploadFile', payload, this.onUploadFile(payload)), updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), openWorkspacePath: payload => this.record( diff --git a/packages/api/session-controller/tests/file-upload-http.host.spec.ts b/packages/api/session-controller/tests/file-upload-http.host.spec.ts index 32ef3990c6..60fd54fea9 100644 --- a/packages/api/session-controller/tests/file-upload-http.host.spec.ts +++ b/packages/api/session-controller/tests/file-upload-http.host.spec.ts @@ -1,12 +1,8 @@ import { runInNewContext } from 'node:vm' -import { Context } from '@deepseek-ai/cordis' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' -import type { SessionCommandController } from '../src/commands.ts' -import { - handleSessionFileUploadHttp, registerSessionFileUploadHttp, -} from '../src/file-upload-http.ts' +import { FileUploads, handleFileUploadHttp } from '@deepseek-ai/dsh-client-file-upload' function request(input: { method?: string @@ -26,63 +22,49 @@ function request(input: { }) } -function commands(result: unknown): SessionCommandController & { - uploadFileStream: Mock +function uploads(result: unknown): FileUploads & { + uploadStream: Mock uploadedChunks: Uint8Array[] } { const uploadedChunks: Uint8Array[] = [] - const uploadFileStream = vi.fn(async (input) => { + const uploadStream = vi.fn(async (input) => { for await (const chunk of input.data) uploadedChunks.push(chunk) - return await result as Awaited> + return await result as Awaited> }) return { uploadedChunks, - uploadFileStream, - } as unknown as SessionCommandController & { - uploadFileStream: Mock + uploadStream, + } as unknown as FileUploads & { + uploadStream: Mock uploadedChunks: Uint8Array[] } } describe('background file upload Fetch route', () => { - it('registers one authenticated POST route on Connection', async () => { - const ctx = new Context() - const register = vi.fn((_route: unknown) => vi.fn(async () => {})) - ctx.provide('connection', { fetch: { register } } as never) - registerSessionFileUploadHttp(ctx, commands(Promise.resolve({}))) - await vi.waitFor(() => { expect(register).toHaveBeenCalledOnce() }) - const route = register.mock.calls[0]?.[0] as { - path: string - methods: string[] - requestBody: string - fetch(request: Request): Promise - } - expect(route.path).toBe('/api/session/uploadFileBinary') - expect(route.methods).toEqual(['POST']) - expect(route.requestBody).toBe('streaming') - expect((await route.fetch(request({ + it('accepts one authenticated streaming POST request', async () => { + const service = uploads(Promise.resolve({})) + expect((await handleFileUploadHttp(service, request({ sessionId: 's1', contentType: 'application/octet-stream', }))).status).toBe(200) - await ctx.fiber.dispose() }) it('rejects the wrong method, media type, and missing Session id without storing', async () => { - const controller = commands(Promise.resolve({})) - const wrongMethod = await handleSessionFileUploadHttp(controller, request({ method: 'GET' })) + const service = uploads(Promise.resolve({})) + const wrongMethod = await handleFileUploadHttp(service, request({ method: 'GET' })) expect(wrongMethod.status).toBe(405) expect(wrongMethod.headers.get('allow')).toBe('POST') - const wrongType = await handleSessionFileUploadHttp(controller, request({ contentType: 'application/json' })) + const wrongType = await handleFileUploadHttp(service, request({ contentType: 'application/json' })) expect(wrongType.status).toBe(415) expect(await wrongType.text()).toBe('content type must be application/octet-stream') - const missingSession = await handleSessionFileUploadHttp( - controller, + const missingSession = await handleFileUploadHttp( + service, request({ contentType: 'application/octet-stream' }), ) expect(missingSession.status).toBe(400) expect(await missingSession.text()).toBe('sessionId is required') - expect(controller.uploadFileStream).not.toHaveBeenCalled() + expect(service.uploadStream).not.toHaveBeenCalled() }) it('stores the request bytes and returns the staged receipt', async () => { @@ -90,18 +72,18 @@ describe('background file upload Fetch route', () => { receiptId: 'receipt-1', file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 }, } - const controller = commands(Promise.resolve(value)) - const response = await handleSessionFileUploadHttp(controller, request({ + const service = uploads(Promise.resolve(value)) + const response = await handleFileUploadHttp(service, request({ sessionId: 's1', name: 'large & final.bin', contentType: 'application/octet-stream; charset=binary', body: Uint8Array.of(1, 2, 3, 4), })) - expect(controller.uploadFileStream).toHaveBeenCalledOnce() - const upload = controller.uploadFileStream.mock.calls[0]?.[0] + expect(service.uploadStream).toHaveBeenCalledOnce() + const upload = service.uploadStream.mock.calls[0]?.[0] expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' }) expect(upload?.signal).toBeInstanceOf(AbortSignal) - expect(controller.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)]) + expect(service.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)]) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8') expect(response.headers.get('cache-control')).toBe('no-store') @@ -109,14 +91,14 @@ describe('background file upload Fetch route', () => { }) it('returns business and internal storage failures and keeps an absent name absent', async () => { - const business = commands(Promise.reject(new RemoteError( + const business = uploads(Promise.reject(new RemoteError( 'session/attachment-invalid', 'denied', { reason: 'NOPE' }, ))) - const businessResponse = await handleSessionFileUploadHttp(business, request({ + const businessResponse = await handleFileUploadHttp(business, request({ sessionId: 's1', contentType: 'application/octet-stream', })) - expect(business.uploadFileStream).toHaveBeenCalledOnce() - const upload = business.uploadFileStream.mock.calls[0]?.[0] + expect(business.uploadStream).toHaveBeenCalledOnce() + const upload = business.uploadStream.mock.calls[0]?.[0] expect(upload).toMatchObject({ sessionId: 's1' }) expect(upload?.signal).toBeInstanceOf(AbortSignal) expect(business.uploadedChunks).toEqual([]) @@ -125,16 +107,16 @@ describe('background file upload Fetch route', () => { error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, }) - const internal = commands(Promise.reject(new Error('disk offline'))) - expect(await (await handleSessionFileUploadHttp(internal, request({ + const internal = uploads(Promise.reject(new Error('disk offline'))) + expect(await (await handleFileUploadHttp(internal, request({ sessionId: 's1', contentType: 'application/octet-stream', }))).json()).toEqual({ ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} }, }) const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error - const exception = commands(Promise.reject(foreignError)) - expect(await (await handleSessionFileUploadHttp(exception, request({ + const exception = uploads(Promise.reject(foreignError)) + expect(await (await handleFileUploadHttp(exception, request({ sessionId: 's1', contentType: 'application/octet-stream', }))).json()).toEqual({ ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} }, diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index b307c46f7d..dbbf4b7955 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,8 +1,6 @@ /** Session object lifecycle, event-window transport, commands, and resync behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { FileUploadService } from '@deepseek-ai/dsh-client-file-upload/client' import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' @@ -17,7 +15,6 @@ const PARENT = 'fk-parent' as SessionId afterEach(() => { vi.unstubAllGlobals() }) - function makeSession( api = new FakeApiClient(), options: SessionOptions = {}, @@ -25,16 +22,6 @@ function makeSession( return { api, session: new Session(SID, fakeRemote(api), options) } } -function bindFileUpload( - session: Session, - post: FileUploadService['post'], - available = true, -): void { - const ctx = new Context() - ctx.reflect.provide('fileUpload', { available, post }) - session.bindScope(ctx) -} - function follow( api: FakeApiClient, event: SessionEvent, @@ -57,177 +44,6 @@ function histResponse(events: SessionEvent[], hasMore = false) { return Promise.resolve(ok(historyValue(events, hasMore))) } -describe('Session file upload', () => { - it('uses the background body carrier, reports progress, and validates its receipt', async () => { - const progress = vi.fn() - const post = vi.fn(async (request: { - path: string - body: Blob | ReadableStream - headers?: Readonly> - signal?: AbortSignal - onProgress?: (progress: { loaded: number; total?: number }) => void - }) => { - request.onProgress?.({ loaded: 2, total: 4 }) - return { - status: 200, - body: JSON.stringify({ - ok: true, - value: { - receiptId: 'receipt-1', - file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, - }, - }), - } - }) - const { api, session } = makeSession() - bindFileUpload(session, post) - const abort = new AbortController() - const file = new Blob([Uint8Array.of(1, 2, 3, 4)]) - - await expect(session.uploadFile(file, 'notes & refs.pdf', abort.signal, progress)).resolves.toEqual({ - ok: true, - value: { - receiptId: 'receipt-1', - file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, - }, - }) - expect(post).toHaveBeenCalledWith(expect.objectContaining({ - path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=notes+%26+refs.pdf', - body: file, - headers: { 'content-type': 'application/octet-stream' }, - signal: abort.signal, - })) - expect(progress).toHaveBeenCalledWith({ loaded: 2, total: 4 }) - expect(api.callsOf('session.uploadFile')).toEqual([]) - }) - - it('preserves a background business failure and supports unnamed files without observers', async () => { - const post = vi.fn(() => Promise.resolve({ - status: 200, - body: JSON.stringify({ - ok: false, - error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, - }), - })) - const { session } = makeSession() - bindFileUpload(session, post) - await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({ - ok: false, - error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, - }) - const request = post.mock.calls[0]?.[0] - expect(request).toMatchObject({ - path: '/api/session/uploadFileBinary?sessionId=fk-s1', - headers: { 'content-type': 'application/octet-stream' }, - }) - expect(request?.body).toBeInstanceOf(Blob) - }) - - it('keeps byte and Blob fallback uploads on the generated Remote carrier', async () => { - const { api, session } = makeSession() - await expect(session.uploadFile(Uint8Array.of(0, 0, 0), 'bytes.bin')).resolves.toMatchObject({ ok: true }) - await expect(session.uploadFile(new Blob([Uint8Array.of(1)]))).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('session.uploadFile')).toEqual([ - { sessionId: SID, data: 'AAAA', name: 'bytes.bin' }, - { sessionId: SID, data: 'AQ==' }, - ]) - }) - - it('keeps fixture Blob fallback on the Remote and refuses an uncarried stream', async () => { - const { api, session } = makeSession() - const post = vi.fn() - bindFileUpload(session, post, false) - - await expect(session.uploadFile(new Blob([Uint8Array.of(1)]), 'fixture.bin')) - .resolves.toMatchObject({ ok: true }) - const stream = new ReadableStream({ start(controller) { controller.close() } }) - await expect(session.uploadFile(stream, 'stream.bin')) - .rejects.toThrow('stream file upload requires a bound Client file-upload service') - expect(post).not.toHaveBeenCalled() - expect(api.callsOf('session.uploadFile')).toEqual([ - { sessionId: SID, data: 'AQ==', name: 'fixture.bin' }, - ]) - }) - - it('hands a one-shot ReadableStream to the scoped file-upload service', async () => { - const post = vi.fn(() => Promise.resolve({ - status: 200, - body: JSON.stringify({ - ok: true, - value: { - receiptId: 'stream-receipt', - file: { attachmentId: 'stream-file', name: 'stream.bin', bytes: 3 }, - }, - }), - })) - const { session } = makeSession() - bindFileUpload(session, post) - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(Uint8Array.of(1, 2, 3)) - controller.close() - }, - }) - await expect(session.uploadFile(stream, 'stream.bin')).resolves.toMatchObject({ ok: true }) - expect(post).toHaveBeenCalledWith(expect.objectContaining({ - path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=stream.bin', - body: stream, - })) - }) - - it('refuses a stream when a bare Session has no scoped upload service', async () => { - const { session } = makeSession() - const stream = new ReadableStream({ start(controller) { controller.close() } }) - await expect(session.uploadFile(stream)).rejects.toThrow( - 'stream file upload requires a bound Client file-upload service', - ) - }) - - it('folds non-200 and malformed background responses into transport failures', async () => { - const bodies: unknown[] = [ - null, - { ok: 'yes' }, - { ok: false, error: null }, - { ok: false, error: { code: 1, message: 'x', details: {} } }, - { ok: false, error: { code: 'x', message: 1, details: {} } }, - { ok: false, error: { code: 'x', message: 'x', details: null } }, - { ok: true, value: null }, - { ok: true, value: { receiptId: 1, file: {} } }, - { ok: true, value: { receiptId: 'r', file: null } }, - { ok: true, value: { receiptId: 'r', file: { attachmentId: 1, name: 'x', bytes: 1 } } }, - { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 1, bytes: 1 } } }, - { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: '1' } } }, - { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: 1.5 } } }, - { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, - ] - for (const body of bodies) { - const { session } = makeSession() - bindFileUpload(session, () => Promise.resolve({ status: 200, body: JSON.stringify(body) })) - await expect(session.uploadFile(new Blob([]))) - .rejects.toThrow(/file upload transport returned an invalid/) - } - const { session } = makeSession() - bindFileUpload(session, () => Promise.resolve({ status: 503, body: 'unavailable' })) - await expect(session.uploadFile(new Blob([]))) - .rejects.toThrow('file upload transport failed with HTTP 503') - }) - - it('refuses a direct file upload for a continuable subagent before either carrier runs', async () => { - const post = vi.fn() - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - }) - bindFileUpload(session, post) - await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({ - ok: false, - error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } }, - }) - expect(post).not.toHaveBeenCalled() - expect(api.callsOf('session.uploadFile')).toEqual([]) - }) -}) - describe('Session open', () => { it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { const { session } = makeSession() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 1b5859b509..531db817cb 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -236,6 +236,14 @@ function installControllers( }, } as never) } + if (ctx.get('fileUploads') === undefined) { + ctx.provide('fileUploads', { + registerAgentResolver: () => () => {}, + resolve: () => undefined, + bindPrompt: () => () => {}, + retirePrompt: () => {}, + } as never) + } installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 4331140c7a..3bb07893e2 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -7,7 +7,6 @@ }, "include": [ "src/client/**/*.ts", - "src/file-upload-path.ts", "src/types.ts", "src/remote-events.ts" ], @@ -16,7 +15,7 @@ { "path": "../gateway/tsconfig.client.json" }, { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, - { "path": "../../client/file-upload" }, + { "path": "../../client/file-upload/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../context/file-reference" }, { "path": "../../core/session" }, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index a5c52b3274..e037d3c3cc 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -14,8 +14,6 @@ "src/commands.ts", "src/control.ts", "src/file-references.ts", - "src/file-upload-path.ts", - "src/file-upload-http.ts", "src/history.ts", "src/list.ts", "src/model-selection-projection.ts", @@ -30,6 +28,7 @@ { "path": "../../core/session" }, { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, + { "path": "../../client/file-upload/tsconfig.host.json" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index 4dfc341b3f..17fe405c3a 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -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/file-upload/README.md -README.md: 9abd4106dbd7cd1cb9671f8f830952f6faff11c6 -README.zh.md: 4ad14ca5a91acd482f54cab74fb974208a264a5a +README.md: be7f923fc7769bb337597a53a82b22be73866b8f +README.zh.md: fb355562c1249f85585cef4cbbaaa674a8ced690 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index 9abd4106db..be7f923fc7 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -1,5 +1,5 @@ --- -description: "Background browser uploads for raw Blob and ReadableStream bodies, including worker transfer, progress, and cancellation." +description: "Agent-scoped browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets browser features upload a `Blob` or `ReadableStream` without aggregating its bytes on the page thread. Served pages send each body through a dedicated Worker; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page reports the background carrier as unavailable so its Session adapter can keep using the generated in-memory Remote. +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` under one Agent scope and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs. ## Table of Contents @@ -25,14 +25,14 @@ This package lets browser features upload a `Blob` or `ReadableStream ## Use this package -Mount the Client plugin before a consumer that injects `fileUpload`, then call `ctx.fileUpload.post()` with a same-origin path and one raw body. +Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`. The service derives the wire identity from `agentContext`; callers do not assemble an upload URL or Remote request. ```yaml - id: file-upload name: '@deepseek-ai/dsh-client-file-upload' ``` -The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. +The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the scoped generated Remote. ----- @@ -42,17 +42,21 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins
Implementation internals — click to expand -The Client plugin provides one inherited `ctx.fileUpload` service. Its provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Consumers inspect `ctx.fileUpload.available` before selecting it. Without a hook, each non-fixture request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. +The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. + +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. | File | Role | |---|---| -| [`src/client/contract.ts`](src/client/contract.ts) | Cordis service request, response, progress, and page-hook types | +| [`src/index.ts`](src/index.ts) | Host streaming route, storage, and Agent-scoped receipt lifecycle | +| [`src/types.ts`](src/types.ts) | encoded request, receipt, and durable result types | +| [`src/client/contract.ts`](src/client/contract.ts) | Client upload, progress, and page-hook types | | [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations | | [`src/client/index.ts`](src/client/index.ts) | Client plugin registration and `ctx.fileUpload` declaration |
-**Runtime invariant:** No companion is published. Each request uses exactly one selected carrier, and fixture or unsupported-browser requests fail before the body is sent. +**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Agent scope, and each request uses one selected carrier. Unscoped calls and unsupported stream carriers fail before the body is sent. ----- @@ -60,7 +64,7 @@ The Client plugin provides one inherited `ctx.fileUpload` service. Its provider ## Further Exploration - [Connection](../connection/README.md) — authenticated RPC, exact Host routes, and connection generations. -- [Session Controller](../../api/session-controller/README.md) — the raw file-upload route and staged Session receipt. +- [Session Controller](../../api/session-controller/README.md) — prompt admission that consumes staged receipts. - [Web Worker runtime](../../experimental/webworker-runtime/README.md) — the page-to-Host Worker request tunnel. - [Client group map](../README.md) — browser services and UI feature packages. diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index 4ad14ca5a9..fb355562c1 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -1,5 +1,5 @@ --- -description: "在浏览器后台上传原始 Blob 与 ReadableStream,包括 Worker 转交、进度和取消。" +description: "按 Agent scope 上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让浏览器功能上传 `Blob` 或 `ReadableStream`,同时避免在页面线程聚合全部字节。普通服务页面通过专用 Worker 发送每个请求体;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面会报告后台载体不可用,让 Session adapter 继续使用生成的内存 Remote。 +本包让浏览器功能在一个 Agent scope 下存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。 ## 目录 @@ -25,14 +25,14 @@ kind: "package-reference" ## 使用本包 -在注入 `fileUpload` 的消费方之前挂载 Client 插件,再用同源路径和一个原始请求体调用 `ctx.fileUpload.post()`。 +在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`。服务从 `agentContext` 得到 wire identity;调用方不组装上传 URL 或 Remote 请求。 ```yaml - id: file-upload name: '@deepseek-ai/dsh-client-file-upload' ``` -本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。 +本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用按 scope 寻址的生成 Remote。 ----- @@ -42,17 +42,21 @@ kind: "package-reference"
实现细节——点击展开 -Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。消费者在选择服务前检查 `ctx.fileUpload.available`。没有该钩子时,每个非 fixture 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 +Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 + +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。 | 文件 | 职责 | |---|---| -| [`src/client/contract.ts`](src/client/contract.ts) | Cordis 服务的请求、响应、进度与页面钩子类型 | +| [`src/index.ts`](src/index.ts) | Host 流式路由、存储与按 Agent scope 管理的凭证生命周期 | +| [`src/types.ts`](src/types.ts) | 编码请求、凭证与持久结果类型 | +| [`src/client/contract.ts`](src/client/contract.ts) | Client 上传、进度与页面钩子类型 | | [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 | | [`src/client/index.ts`](src/client/index.ts) | Client 插件注册与 `ctx.fileUpload` 声明 |
-**运行时不变式:** 不发布伴生入口。每个请求只使用一个已选定载体;fixture 或不支持的浏览器请求会在发送请求体前失败。 +**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Agent scope,每个请求只使用一个已选定载体。没有 scope 的调用和载体不支持的 stream 会在发送请求体前失败。 ----- @@ -60,7 +64,7 @@ Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其 ## 进一步探索 - [Connection](../connection/README.zh.md)——认证 RPC、Host 精确路由与 connection generation。 -- [Session Controller](../../api/session-controller/README.zh.md)——原始文件上传路由与按 Session 暂存的凭证。 +- [Session Controller](../../api/session-controller/README.zh.md)——消费暂存凭证的 prompt 准入。 - [Web Worker runtime](../../experimental/webworker-runtime/README.zh.md)——页面到 Host Worker 的请求隧道。 - [客户端组地图](../README.zh.md)——浏览器服务与 UI 功能包。 diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 9cbc484b84..3d098ab6b5 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-file-upload", - "description": "Browser background upload service for Blob and ReadableStream request bodies", + "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", "version": "0.1.2-rc.1", "publishConfig": { "access": "public" @@ -22,12 +22,26 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "dsh": { "client": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], "platform": "web", "immediately": true } @@ -40,12 +54,35 @@ "files": [ "lib/index.js", "lib/client.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" } } diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 28ef800c9a..936cb21b38 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -7,32 +7,29 @@ export interface FileUploadProgress { readonly total?: number } -/** One background upload request. */ -export interface FileUploadRequest { - readonly path: string - readonly body: FileUploadBody - readonly headers?: Readonly> - readonly signal?: AbortSignal - readonly onProgress?: (progress: FileUploadProgress) => void -} - -/** Small response returned after the service has sent the body. */ -export interface FileUploadResponse { - readonly status: number - readonly body: string -} - -/** Browser upload service inherited by every Client Cordis scope. */ +/** Browser upload service addressed through one Client Agent scope. */ export interface FileUploadService { /** Whether this page has a Host-backed background upload carrier. */ readonly available: boolean /** - * Post one Blob or one-shot byte stream without aggregating it on the page thread. - * The call consumes a stream body and rejects with `AbortError` when its signal fires. - * @param request - target, body, cancellation, and progress observer. - * @returns the response status and text body. + * Store one file under an Agent scope. Blob and stream bodies use + * the background carrier; exact bytes and fixture fallbacks use Remote. + * @param owner - Agent-scoped Client context that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. */ - post(request: FileUploadRequest): Promise + upload( + owner: Context, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise> } /** @@ -47,3 +44,4 @@ export type FileUploadFetch = (input: URL, init: RequestInit) => Promise> + readonly signal?: AbortSignal + readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void +} + +interface FileUploadResponse { + readonly status: number + readonly body: string +} + +interface AgentFileUploadContext extends Context { + readonly remote: { + readonly fileUploads: { + upload( + request: import('../types.ts').EncodedFileUploadRequest, + signal?: AbortSignal, + ): Promise> + } + } +} + +interface FileUploadTypert { + readonly contexts: Pick +} + interface UploadWorkerStart { readonly url: string readonly body: FileUploadBody @@ -154,6 +185,57 @@ export class FileUploadRuntime extends Service implements FileUploadService { if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode')) return this.transport.post(request) } + + /** + * Store one file under an Agent scope. + * @param owner - Agent-scoped Client context that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + async upload( + owner: Context, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + const sessionId = this.sessionId(owner) + if (!(data instanceof Uint8Array) && this.available) { + const query = new URLSearchParams({ sessionId }) + if (name !== undefined) query.set('name', name) + const response = await this.post({ + path: `${FILE_UPLOAD_PATH}?${query.toString()}`, + body: data, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return parseFileUploadResult(response.body) + } + if (!(data instanceof Uint8Array) && !(data instanceof Blob)) { + throw new Error('stream file upload requires a background carrier') + } + const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) + return (owner as AgentFileUploadContext).remote.fileUploads.upload({ + data: bytesToBase64(bytes), + ...(name === undefined ? {} : { name }), + }, signal) + } + + private sessionId(owner: Context): string { + const typert = this.ctx.get('typert') as FileUploadTypert | undefined + const sessionId = typert?.contexts.getClient('agent')?.identity(owner) + if (typeof sessionId !== 'string' || sessionId === '') { + throw new Error('fileUpload.upload requires an Agent-scoped context') + } + return sessionId + } } function customTransport(customFetch: FileUploadFetch): FileUploadTransport { @@ -246,3 +328,43 @@ function isFixturePage(): boolean { && 'search' in pageLocation && typeof pageLocation.search === 'string' && new URLSearchParams(pageLocation.search).has('fixture') } + +function parseFileUploadResult(body: string): RemoteResult { + const value = JSON.parse(body) as unknown + if (!isRecord(value) || typeof value.ok !== 'boolean') { + throw new TypeError('file upload transport returned an invalid result') + } + if (!value.ok) { + const error = value.error + if (!isRecord(error) || typeof error.code !== 'string' + || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('file upload transport returned an invalid failure') + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + } + } + const result = value.value + const file = isRecord(result) ? result.file : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' + || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + throw new TypeError('file upload transport returned an invalid receipt') + } + return { + ok: true, + value: { + receiptId: result.receiptId as FileUploadValue['receiptId'], + file: { + attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, + }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 8cc5773cca..29d318c099 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -1,4 +1,314 @@ -/** Host half of the browser-only file-upload service. */ +/** Host file-upload service: streamed intake and Agent-scoped staged receipts. */ -/** No Host behavior; the Client half provides the browser upload service. */ -export function apply(): void {} +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AttachmentError, admitEncodedFile } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { FILE_UPLOAD_PATH } from './protocol.ts' +import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' + +export { FILE_UPLOAD_PATH } from './protocol.ts' +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host storage and staged-receipt service for browser file uploads. */ + fileUploads: FileUploads + } +} + +interface StagedFileUpload { + readonly file: FileAttachmentRef + /** Prompt that accepted this receipt; absent until successful admission. */ + requestId?: string +} + +/** Resolve or resume the ordinary Agent that owns one Session identity. */ +export type AgentResolver = (sessionId: SessionId) => Promise + +type FileUploadHttpResult = + | { readonly ok: true; readonly value: FileUploadValue } + | { + readonly ok: false + readonly error: { readonly code: string; readonly message: string; readonly details: object } + } + +/** Host service owning upload storage and Agent-scoped staged receipts. */ +export class FileUploads extends TypertRemoteService { + static inject = ['agents', 'attachments', 'commands', 'connection'] + + private readonly stagedFiles = new WeakMap>() + private agentResolver: AgentResolver | undefined + + /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ + constructor(ctx: Context) { + super(ctx, 'fileUploads') + const resolve: CommandFileReceiptResolver = (agent, receiptId) => + this.resolve(agent, receiptId as FileUploadReceiptId) + ctx.effect( + () => ctx.commands.registerFileReceiptResolver(resolve), + 'file-upload: command file receipt resolver', + ) + ctx.effect( + () => (ctx.connection as HostConnectionHandle).fetch.register({ + path: FILE_UPLOAD_PATH, + methods: ['POST'], + requestBody: 'streaming', + fetch: request => handleFileUploadHttp(this, request), + }), + 'file-upload: streaming route', + ) + ctx.on('session/event', (session, event) => { this.observeSessionEvent(session, event) }) + ctx.on('session/disposed', (session) => { this.stagedFiles.delete(session) }) + } + + /** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ + registerAgentResolver(resolve: AgentResolver): () => void { + if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered') + this.agentResolver = resolve + return () => { + if (this.agentResolver === resolve) this.agentResolver = undefined + } + } + + /** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ + @Remote('upload') + upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commit(agent, async () => admitEncodedFile(this.ctx.attachments, { + data: request.data, + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ + async uploadStream(request: { + readonly sessionId: SessionId + readonly data: AsyncIterable + readonly signal?: AbortSignal + readonly name?: string + }): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.commit(agent, async () => this.ctx.attachments.saveFileStream({ + data: request.data, + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ + resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { + this.assertAgentScope(agent) + return this.stagedFiles.get(agent.session)?.get(receiptId)?.file + } + + /** + * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns rollback restoring every prior binding. + */ + bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void { + this.assertAgentScope(agent) + const staged = this.stagedFiles.get(agent.session) + const bound = receiptIds.map((receiptId) => { + const upload = staged?.get(receiptId) + if (upload === undefined) throw fileNotStaged() + return { upload, previous: upload.requestId } + }) + for (const { upload } of bound) upload.requestId = requestId + return () => { + for (const { upload, previous } of bound) { + if (previous === undefined) delete upload.requestId + else upload.requestId = previous + } + } + } + + /** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ + retirePrompt(agent: Agent, requestId: string): void { + this.assertAgentScope(agent) + this.retire(agent.session, requestId) + } + + private async commit(agent: Agent, save: () => Promise): Promise { + this.assertOrdinaryAgent(agent) + let file: FileAttachmentRef + try { + file = await save() + } catch (error) { + if (error instanceof AttachmentError) { + throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never) + } + throw new RemoteError( + 'gateway/internal', + `failed to store file upload: ${String(error)}`, + {}, + { cause: error }, + ) + } + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed before its file upload completed`, + { sessionId: agent.id }, + ) + } + let staged = this.stagedFiles.get(agent.session) + if (staged === undefined) { + staged = new Map() + this.stagedFiles.set(agent.session, staged) + } + const receiptId = randomUUID() as FileUploadReceiptId + staged.set(receiptId, { file }) + return { receiptId, file } + } + + private async resolveAgent(sessionId: SessionId): Promise { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) return live + const resolver = this.agentResolver + if (resolver === undefined) { + throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId }) + } + return resolver(sessionId) + } + + private assertAgentScope(agent: Agent): void { + if (scopeOf(agent.ctx) !== agent) throw new Error('file-upload: operation requires the Agent\'s own scope') + } + + private assertOrdinaryAgent(agent: Agent): void { + this.assertAgentScope(agent) + if (agent.session.header.origin === 'subagent') { + throw new RemoteError( + 'subagent/attachment-invalid' as never, + 'subagent conversations do not accept file uploads', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' } as never, + ) + } + } + + private observeSessionEvent(session: Session, event: SessionEvent): void { + if (event.type !== 'user/message' || event.data.source.kind !== 'user' + || !('rpcId' in event.data.source)) return + if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId) + } + + private retire(session: Session, requestId: string): void { + const staged = this.stagedFiles.get(session) + if (staged === undefined) return + for (const [receiptId, upload] of staged) { + if (upload.requestId === requestId) staged.delete(receiptId) + } + if (staged.size === 0) this.stagedFiles.delete(session) + } +} + +/** + * Handle one authenticated raw-byte upload. + * @param service - Host upload service receiving streamed bytes. + * @param request - authenticated HTTP request from Connection. + * @returns JSON result using HTTP status 200 after request validation. + */ +export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise { + if (request.method !== 'POST') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }) + } + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/octet-stream') { + return new Response('content type must be application/octet-stream', { status: 415 }) + } + const url = new URL(request.url) + const sessionId = url.searchParams.get('sessionId') + if (sessionId === null || sessionId === '') { + return new Response('sessionId is required', { status: 400 }) + } + const name = url.searchParams.get('name') ?? undefined + let result: FileUploadHttpResult + try { + result = { + ok: true, + value: await service.uploadStream({ + sessionId: SessionId(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + } + } catch (error) { + const failure = remoteErrorOf(error) + result = { + ok: false, + error: failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: 'gateway/internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function* requestBodyChunks(body: ReadableStream | null): AsyncIterable { + if (body === null) return + const reader = body.getReader() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) return + yield chunk.value + } + } finally { + reader.releaseLock() + } +} + +function fileNotStaged(): RemoteError { + return new RemoteError( + 'session/attachment-invalid' as never, + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' } as never, + ) +} + +export default FileUploads diff --git a/packages/client/file-upload/src/protocol.ts b/packages/client/file-upload/src/protocol.ts new file mode 100644 index 0000000000..67ec32811a --- /dev/null +++ b/packages/client/file-upload/src/protocol.ts @@ -0,0 +1,2 @@ +/** Authenticated raw-byte route owned by the file-upload service. */ +export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts new file mode 100644 index 0000000000..d1f750bd56 --- /dev/null +++ b/packages/client/file-upload/src/types.ts @@ -0,0 +1,22 @@ +/** Browser-safe request and receipt types for staged file uploads. */ + +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Canonical encoded upload accepted by the Remote fallback. */ +export interface EncodedFileUploadRequest { + /** Canonical base64 encoding of the exact file bytes. */ + readonly data: string + /** Optional display name; the Host sanitizes it into the stored leaf name. */ + readonly name?: string +} + +/** Durable receipt for one staged file upload. */ +export interface FileUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId + readonly file: FileAttachmentRef +} + +/** Host-minted authority for one staged file upload in one Agent scope. */ +export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index e8344acf6d..8b80d85d48 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -1,6 +1,5 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply as applyHost } from '../src/index.ts' import { apply } from '../src/client/index.ts' import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' import type { ClientFileUploadHooks, FileUploadBody } from '../src/client/contract.ts' @@ -211,10 +210,6 @@ describe('file upload worker body', () => { }) describe('file upload service', () => { - it('keeps the Host half inert', () => { - expect(() => { applyHost() }).not.toThrow() - }) - it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { vi.stubGlobal('location', { origin: 'https://preview.test' }) const fetch = vi.fn((_url: URL, _init?: RequestInit) => @@ -225,7 +220,7 @@ describe('file upload service', () => { await fiber const blob = new Blob(['opaque']) const signal = new AbortController().signal - await expect(ctx.fileUpload.post({ + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, })).resolves.toEqual({ status: 202, body: 'accepted' }) expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), { @@ -233,7 +228,7 @@ describe('file upload service', () => { }) const stream = new ReadableStream({ start(controller) { controller.close() } }) - await ctx.fileUpload.post({ path: '/stream', body: stream }) + await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), { method: 'POST', body: stream, duplex: 'half', }) @@ -248,7 +243,7 @@ describe('file upload service', () => { const fiber = ctx.plugin({ apply }) await fiber const body = new Blob() - await ctx.fileUpload.post({ path: '/fallback', body }) + await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body }) expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { method: 'POST', body, }) @@ -261,7 +256,7 @@ describe('file upload service', () => { const fiber = ctx.plugin(FileUploadRuntime) await fiber expect(ctx.fileUpload.available).toBe(false) - await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) .rejects.toThrow('background upload is unavailable in fixture mode') await fiber.dispose() }) @@ -271,7 +266,7 @@ describe('file upload service', () => { const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber - await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) .rejects.toThrow('background upload requires Web Worker support') await fiber.dispose() }) @@ -294,7 +289,7 @@ describe('file upload service', () => { await fiber const progress = vi.fn() const blob = new Blob(['bytes']) - const pending = ctx.fileUpload.post({ path: '/api/upload', body: blob, onProgress: progress }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress }) const worker = FakeWorker.last if (worker === undefined) throw new Error('worker missing') expect(created).toHaveBeenCalledOnce() @@ -331,7 +326,7 @@ describe('file upload service', () => { const fiber = ctx.plugin(FileUploadRuntime) await fiber const stream = new ReadableStream({ start(controller) { controller.close() } }) - const pending = ctx.fileUpload.post({ path: '/stream', body: stream }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) const worker = FakeWorker.last if (worker === undefined) throw new Error('worker missing') expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream]) @@ -356,29 +351,152 @@ describe('file upload service', () => { const fiber = ctx.plugin(FileUploadRuntime) await fiber - const reported = ctx.fileUpload.post({ path: '/upload', body: new Blob() }) + const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) await expect(reported).rejects.toThrow('network failed') - const errored = ctx.fileUpload.post({ path: '/upload', body: new Blob() }) + const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) await expect(errored).rejects.toThrow('worker crashed') - const unnamed = ctx.fileUpload.post({ path: '/upload', body: new Blob() }) + const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) await expect(unnamed).rejects.toThrow('background upload worker failed') const controller = new AbortController() - const aborted = ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: controller.signal }) + const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal }) controller.abort() await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() const already = new AbortController() already.abort() - await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: already.signal })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal })) .rejects.toMatchObject({ name: 'AbortError' }) expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() await fiber.dispose() }) }) + +describe('Agent-scoped file upload', () => { + async function scopedService(options: { + readonly sessionId?: string + readonly remote?: ReturnType + } = {}) { + const ctx = new Context() + ctx.provide('sessions', { + scopeOf: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined, + } as never) + ctx.provide('typert', { + contexts: { + getClient: (kind: string) => kind === 'agent' + ? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') } + : undefined, + }, + } as never) + const remote = options.remote ?? vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'remote-receipt', + file: { attachmentId: 'remote-file', name: 'file', bytes: 3 }, + }, + })) + ctx.provide('remote', { fileUploads: { upload: remote } } as never) + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const owner = options.sessionId === undefined + ? ctx + : ctx.extend({ fixtureSessionId: options.sessionId }) + return { ctx, fiber, owner, remote, service: ctx.fileUpload } + } + + it('assembles the scoped streaming request and parses progress and receipt fields', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const progress = vi.fn() + const fetch = vi.fn((_url: URL, init: RequestInit) => { + expect(init.body).toBeInstanceOf(Blob) + progress({ loaded: 2, total: 4 }) + return Promise.resolve(new Response(JSON.stringify({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }), { status: 200 })) + }) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const { fiber, owner, service } = await scopedService({ sessionId: 's1' }) + const signal = new AbortController().signal + const file = new Blob(['data']) + + await expect(service.upload(owner, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }) + expect(fetch).toHaveBeenCalledWith( + new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'), + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/octet-stream' }, + body: file, + signal, + }), + ) + await fiber.dispose() + }) + + it('uses the scoped Remote fallback for exact bytes and fixture Blob bodies', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const remote = vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'remote-receipt', + file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 }, + }, + })) + const { fiber, owner, service } = await scopedService({ sessionId: 's1', remote }) + await expect(service.upload(owner, Uint8Array.of(0, 0, 0), 'bytes.bin')) + .resolves.toMatchObject({ ok: true }) + await expect(service.upload(owner, new Blob([Uint8Array.of(1)]))) + .resolves.toMatchObject({ ok: true }) + expect(remote.mock.calls).toEqual([ + [{ data: 'AAAA', name: 'bytes.bin' }, undefined], + [{ data: 'AQ==' }, undefined], + ]) + await fiber.dispose() + }) + + it('rejects an unscoped call, an unavailable stream, and malformed background results', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const unscoped = await scopedService() + await expect(unscoped.service.upload(unscoped.owner, Uint8Array.of(1))) + .rejects.toThrow('fileUpload.upload requires an Agent-scoped context') + await unscoped.fiber.dispose() + + const fixture = await scopedService({ sessionId: 's1' }) + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await expect(fixture.service.upload(fixture.owner, stream)) + .rejects.toThrow('stream file upload requires a background carrier') + await fixture.fiber.dispose() + + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const bodies: unknown[] = [ + null, + { ok: 'yes' }, + { ok: false, error: null }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, + ] + for (const body of bodies) { + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), + } + const malformed = await scopedService({ sessionId: 's1' }) + await expect(malformed.service.upload(malformed.owner, new Blob())) + .rejects.toThrow(/file upload transport returned an invalid/) + await malformed.fiber.dispose() + } + }) +}) diff --git a/packages/client/file-upload/tsconfig.client.json b/packages/client/file-upload/tsconfig.client.json new file mode 100644 index 0000000000..75f6822c60 --- /dev/null +++ b/packages/client/file-upload/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/contract.ts", + "src/client/index.ts", + "src/client/runtime.ts", + "src/protocol.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../util/crypto" }, + { "path": "../../attachment/attachment" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/client/file-upload/tsconfig.host.json b/packages/client/file-upload/tsconfig.host.json new file mode 100644 index 0000000000..d0645185cf --- /dev/null +++ b/packages/client/file-upload/tsconfig.host.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": ["src/index.ts", "src/protocol.ts", "src/types.ts"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../core/agent" }, + { "path": "../../core/scope" }, + { "path": "../../core/session" }, + { "path": "../../attachment/attachment" }, + { "path": "../../interaction/commands" }, + { "path": "../../typert/protocol" }, + { "path": "../connection/tsconfig.host.json" } + ] +} diff --git a/packages/client/file-upload/tsconfig.json b/packages/client/file-upload/tsconfig.json index 4e6197e0d1..2a0b0e33f7 100644 --- a/packages/client/file-upload/tsconfig.json +++ b/packages/client/file-upload/tsconfig.json @@ -1,15 +1,7 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ - { - "path": "../../../vendor/cordis" - } + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } ] } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 9612f08741..5874c7bd61 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,8 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { - CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' +import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 89b8706b37..e9ee73095d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -29,6 +29,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-file-upload", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index b65096f5e6..9cc8f95758 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -44,7 +44,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', + 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', ] /** Conversation runtime configuration. */ @@ -238,11 +238,10 @@ export function apply(ctx: Context, config: Config = Config({})): void { const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { - const target = sessions.binding(nextId)?.session - if (target === undefined) { + if (sessions.binding(nextId) === undefined) { throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) } - concreteConversation(ctx).rebindDraftFiles(target, attachmentIds) + concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds) if (draft !== '') { next.setDraft(draft) from.setDraft('') @@ -331,10 +330,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { return { keyboard: shell, addFiles: (files) => { - const session = sessions.binding(sessionId)?.session - if (session === undefined) return t('file.sessionUnavailable') + if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') try { - const drafts = conversation.createDrafts(session, files) + const drafts = conversation.createDrafts(sessionId, files) if (!shell.addAttachments(drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) } @@ -349,8 +347,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, resolveDraftAttachments: ids => conversation.resolveDraftAttachments(ids), retryFileUpload: (id) => { - const session = sessions.binding(sessionId)?.session - if (session !== undefined) conversation.retryFileUpload(session, id) + if (sessions.binding(sessionId) !== undefined) conversation.retryFileUpload(sessionId, id) }, resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9dc71cd90c..272d84b8e3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -2,7 +2,7 @@ import type { ReactNode, RefObject } from 'react' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' -import type { FileUploadReceiptId } from '@deepseek-ai/dsh-api-session-controller/types' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2e33fe8a26..0cb98f55cd 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -16,6 +16,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { ISessions, PendingSubmissionRetirement, SessionFace, } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-file-upload/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -300,11 +301,11 @@ export class ConversationController extends Service implements IConversation { * with the prompt); every other file becomes a file draft whose background * upload starts immediately and remains owned by this service across Session * navigation until completion or explicit removal. - * @param session - target session owning staged file uploads. + * @param sessionId - target Agent-scope identity. * @param files - browser files to register. * @returns ordered draft descriptors. */ - createDrafts(session: SessionFace, files: readonly File[]): readonly ComposerAttachment[] { + createDrafts(sessionId: SessionId, files: readonly File[]): readonly ComposerAttachment[] { return files.map((file) => { if (isImageMediaType(file.type)) { const attachment = browserDraftAttachment(file) @@ -318,36 +319,36 @@ export class ConversationController extends Service implements IConversation { file, } this.draftAttachments.set(attachment.id, attachment) - this.beginFileUpload(session, attachment) + this.beginFileUpload(sessionId, attachment) return attachment }) } /** * Restart one failed file upload. - * @param session - target session owning staged file uploads. + * @param sessionId - target Agent-scope identity. * @param id - draft attachment id whose upload previously failed. */ - retryFileUpload(session: SessionFace, id: DraftAttachmentId): void { + retryFileUpload(sessionId: SessionId, id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined || attachment.kind !== 'file') return if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return - this.beginFileUpload(session, attachment) + this.beginFileUpload(sessionId, attachment) } /** * Stage carried file drafts again for a new Session. - * @param session - target Session after a Workspace switch. + * @param sessionId - target Agent-scope identity after a Workspace switch. * @param ids - carried draft attachment ids. */ - rebindDraftFiles(session: SessionFace, ids: readonly DraftAttachmentId[]): void { + rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void { for (const id of ids) { const attachment = this.draftAttachments.get(id) - if (attachment?.kind === 'file') this.beginFileUpload(session, attachment) + if (attachment?.kind === 'file') this.beginFileUpload(sessionId, attachment) } } - private beginFileUpload(session: SessionFace, attachment: ComposerFileAttachment): void { + private beginFileUpload(sessionId: SessionId, attachment: ComposerFileAttachment): void { this.fileUploadOperations.get(attachment.id)?.controller.abort() const controller = new AbortController() this.fileUploads.update((draft) => { @@ -362,7 +363,10 @@ export class ConversationController extends Service implements IConversation { try { if (controller.signal.aborted || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return - const result = await session.uploadFile( + const owner = this.requireSessions().scope(sessionId) + if (owner === undefined) throw new Error(`conversation: session "${sessionId}" resolved no Agent scope`) + const result = await this.ctx.fileUpload.upload( + owner, attachment.file, attachment.file.name === '' ? undefined : attachment.file.name, controller.signal, diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index dbeb7348ba..6bb0a6a6ed 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import type { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -28,18 +29,28 @@ function sessionFakeFor() { loadOlder: vi.fn(() => Promise.resolve()), prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), - uploadFile: vi.fn(() => Promise.resolve({ - ok: true, - value: { - receiptId: 'root-receipt' as never, - file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 }, - }, - })), } satisfies SessionBehaviorOverrides } async function bench() { const runtime = await SlotTestRuntime.create() + const rootUpload = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'root-receipt' as never, + file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })) + const uploads = new Map Promise>([[ROOT, rootUpload]]) + runtime.ctx.provide('fileUpload', { + available: true, + upload: (owner: Context, ...args: unknown[]) => { + const id = runtime.sessions.scopeOf(owner) + const upload = id === undefined ? undefined : uploads.get(id) + if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture') + return upload(...args) + }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) @@ -94,7 +105,7 @@ async function bench() { conversationApi(id).injected.hooks.conversationViews return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, - inputApi, viewSource, sessionFake, connectWorkspace, + inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, } } @@ -275,7 +286,7 @@ describe('Conversation inject API', () => { expect(b.composerApi(ROOT).addFiles?.([ new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), ])).toBeNull() - await vi.waitFor(() => { expect(b.sessionFake.uploadFile).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(b.rootUpload).toHaveBeenCalledOnce() }) b.connectWorkspace.mockResolvedValueOnce(ROOT) await resident.selectWorkspace('workspace-1' as WorkspaceId) @@ -283,14 +294,15 @@ describe('Conversation inject API', () => { expect(state.getSnapshot().draft).toBe('carry me') const other = 'other-1' as SessionId - const targetUpload = vi.fn(() => Promise.resolve({ + const targetUpload = vi.fn(() => Promise.resolve({ ok: true, value: { receiptId: 'target-receipt' as never, file: { attachmentId: 'target-file' as never, name: 'draft.pdf', bytes: 1 }, }, })) - await b.runtime.sessions.add({ id: other, session: { uploadFile: targetUpload } }, { current: false }) + b.uploads.set(other, targetUpload) + await b.runtime.sessions.add({ id: other, session: {} }, { current: false }) b.connectWorkspace.mockResolvedValueOnce(other) await resident.selectWorkspace('workspace-2' as WorkspaceId) expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] }) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index de1c023ba6..9805bf189f 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -49,7 +49,6 @@ function fakeSession(): SessionFace { subscribe: listener => snapshot.subscribe(listener), beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }), prompt: () => Promise.reject(new Error('unused fake Session operation')), - uploadFile: () => Promise.reject(new Error('unused fake Session operation')), readAttachment: () => Promise.reject(new Error('unused fake Session operation')), updateQueue: () => Promise.reject(new Error('unused fake Session operation')), cancel: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 3582e0af61..a04cf4c3f1 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -3,7 +3,7 @@ // TestSessions mints tagged scopes through the production createScope, so the // service's scopeOf/binding path runs against production resolution (no local // tag probe). -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { @@ -14,8 +14,26 @@ import { InputHub } from '../src/client/input/hub.ts' import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' +/** Test upload service that preserves each fixture Session's local upload override. */ +class TestFileUpload extends Service { + constructor(ctx: Context) { + super(ctx, 'fileUpload') + } + + upload(owner: Context, ...args: unknown[]): Promise { + const sessions = this.ctx.get('sessions') as { + sessionOf(ctx: Context): object | undefined + } + const session = sessions.sessionOf(owner) as { uploadFile?: (...input: unknown[]) => Promise } | undefined + if (session?.uploadFile === undefined) throw new Error('test file upload has no Session override') + return session.uploadFile(...args) + } +} + async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() + const uploadFiber = runtime.ctx.plugin(TestFileUpload) + await uploadFiber.await() const prompt = vi.fn(( _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) @@ -92,7 +110,7 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') @@ -112,7 +130,7 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') @@ -141,7 +159,7 @@ describe('ConversationController', () => { }, })) ;(session as { uploadFile?: unknown }).uploadFile = uploadFile - const drafts = b.root.createDrafts(session, [ + const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }), ]) @@ -169,7 +187,7 @@ describe('ConversationController', () => { file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 }, }, })) - const drafts = b.root.createDrafts(session, [ + const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }), new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }), ]) @@ -212,7 +230,7 @@ describe('ConversationController', () => { return gate.promise.finally(() => { active -= 1 }) }) ;(session as { uploadFile?: unknown }).uploadFile = uploadFile - const drafts = b.root.createDrafts(session, ['one', 'two', 'three', 'four', 'removed'].map(name => + const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name => new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' }))) expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) @@ -261,7 +279,7 @@ describe('ConversationController', () => { return settled.promise }) ;(session as { uploadFile?: unknown }).uploadFile = uploadFile - const [attachment] = b.root.createDrafts(session, [ + const [attachment] = b.root.createDrafts(session.sessionId, [ new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }), ]) if (attachment === undefined) throw new Error('file draft missing') @@ -309,7 +327,7 @@ describe('ConversationController', () => { }) }) ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload - const [attachment] = b.root.createDrafts(source, [ + const [attachment] = b.root.createDrafts(source.sessionId, [ new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }), ]) if (attachment === undefined) throw new Error('file draft missing') @@ -324,7 +342,8 @@ describe('ConversationController', () => { }, })), } - b.root.rebindDraftFiles(target as never, [attachment.id]) + await b.runtime.sessions.add({ id: 's2', session: target }) + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id]) expect(sourceSignal?.aborted).toBe(true) await vi.waitFor(() => { @@ -349,7 +368,7 @@ describe('ConversationController', () => { }) }) ;(session as { uploadFile?: unknown }).uploadFile = uploadFile - const [attachment] = b.root.createDrafts(session, [ + const [attachment] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }), ]) if (attachment === undefined) throw new Error('file draft missing') @@ -378,7 +397,7 @@ describe('ConversationController', () => { file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 }, }, })) - const [attachment] = b.root.createDrafts(session, [ + const [attachment] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }), ]) if (attachment === undefined) throw new Error('file draft missing') @@ -417,7 +436,7 @@ describe('ConversationController', () => { }) }, ) - b.root.createDrafts(session, [ + b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }), ]) await vi.waitFor(() => { expect(uploadSignal).toBeDefined() }) @@ -476,7 +495,7 @@ describe('sendSession submission echo', () => { it('registers the echo before serialization and prompts with its identity', async () => { const b = await echoBench() try { - const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -522,7 +541,7 @@ describe('sendSession submission echo', () => { file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, }, })) - const drafts = b.root.createDrafts(session, [ + const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }), new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }), @@ -604,7 +623,7 @@ describe('sendSession submission echo', () => { const seedImageUrl = vi.fn(() => true) b.runtime.ctx.provide('uiConversation') b.runtime.ctx.set('uiConversation', { seedImageUrl }) - const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -644,7 +663,7 @@ describe('sendSession submission echo', () => { b.prompt.mockResolvedValueOnce({ ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }), } as never) - const attachments = b.root.createDrafts(session, [ + const attachments = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }), ]) @@ -674,7 +693,7 @@ describe('sendSession submission echo', () => { } vi.stubGlobal('FileReader', FailingReader) try { - const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -754,12 +773,12 @@ describe('draft image dimension probe', () => { } vi.stubGlobal('Image', InstantImage) try { - const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), ]) expect(probed).toMatchObject({ width: 640, height: 480 }) vi.stubGlobal('Image', undefined) - const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [ + const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), ]) expect(unprobed?.kind).toBe('image') diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index ee06b09fcd..14491067c7 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -30,7 +30,7 @@ "path": "../../client/connection/tsconfig.host.json" }, { - "path": "../../client/file-upload" + "path": "../../client/file-upload/tsconfig.client.json" }, { "path": "../../host/webserver" diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index db0633d1da..1b7bcf71e2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -881,6 +881,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'fileUploads', + summary: 'Host service owning upload storage and Agent-scoped staged receipts.', + description: 'Host service owning upload storage and Agent-scoped staged receipts.', + methods: [ + { + signature: 'registerAgentResolver(resolve: AgentResolver): () => void', + description: 'Register the ordinary-Session resolver used when a raw upload addresses a cold Session.', + parameters: [{ name: 'resolve', description: 'resolver that returns the exact live Agent or throws a Remote error.' }], + returns: 'disposer removing this resolver.', + }, + { + signature: '@Remote(\'upload\') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise', + description: 'Persist one encoded upload and stage it under the Agent receiver selected by Typert.', + parameters: [{ name: 'agent', description: 'receiving Agent resolved from the Remote Agent scope.' }, { name: 'request', description: 'canonical base64 bytes and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }], + returns: 'the staged receipt and durable file reference.', + }, + { + signature: 'async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise', + description: 'Persist raw chunks for one Session without aggregating the upload.', + parameters: [{ name: 'request', description: 'Session identity, ordered bytes, cancellation, and optional display name.' }], + returns: 'the staged receipt and durable file reference.', + }, + { + signature: 'resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined', + description: 'Resolve one staged receipt inside its receiving Agent scope.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptId', description: 'opaque receipt minted for one completed upload.' }], + returns: 'durable file reference, or `undefined` for an unknown or foreign receipt.', + }, + { + signature: 'bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void', + description: 'Bind receipts to an accepted prompt and return a rollback for delivery failure.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptIds', description: 'distinct staged receipts referenced by the prompt.' }, { name: 'requestId', description: 'prompt identity later observed in queue or history.' }], + returns: 'rollback restoring every prior binding.', + }, + { + signature: 'retirePrompt(agent: Agent, requestId: string): void', + description: 'Retire every receipt accepted by one removed queue occurrence.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'requestId', description: 'prompt identity carried by the queue occurrence.' }], + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1450,12 +1492,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session and attachment identities used for authorization.' }], returns: 'the durable attachment reference and base64-encoded bytes.', }, - { - signature: '@Remote(\'uploadFile\') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise', - description: 'Persist one encoded file upload verbatim and stage it for a later prompt on the same Session.', - parameters: [{ name: 'request', description: 'Session identity, base64 payload, and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }], - returns: 'an opaque per-upload receipt and the durable file reference.', - }, { signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue', description: 'Mutate one still-pending queue occurrence on a live Agent.', @@ -3451,6 +3487,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPresetRow', declaration: 'export interface AgentPresetRow {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly isDefault: boolean;\n readonly name?: string;\n readonly description?: string;\n readonly broken?: string;\n}', }, + { + name: 'AgentResolver', + declaration: 'export type AgentResolver = (sessionId: SessionId) => Promise;', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', @@ -3995,6 +4035,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'EncodedFileUploadRequest', + declaration: 'export interface EncodedFileUploadRequest {\n readonly data: string;\n readonly name?: string;\n}', + }, { name: 'EncodedImageAttachment', declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', @@ -4031,6 +4075,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileUploadReceiptId', declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', }, + { + name: 'FileUploadValue', + declaration: 'export interface FileUploadValue {\n readonly receiptId: FileUploadReceiptId;\n readonly file: FileAttachmentRef;\n}', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', @@ -5267,14 +5315,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionUpdateQueueValue', declaration: 'export interface SessionUpdateQueueValue {\n readonly accepted: true;\n}', }, - { - name: 'SessionUploadFileRequest', - declaration: 'export interface SessionUploadFileRequest {\n readonly sessionId: SessionId;\n readonly data: string;\n readonly name?: string;\n}', - }, - { - name: 'SessionUploadFileValue', - declaration: 'export interface SessionUploadFileValue {\n readonly receiptId: FileUploadReceiptId;\n readonly file: FileAttachmentRef;\n}', - }, { name: 'SessionWireEvent', declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SessionWireSurfaceOp;\n}', diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 5c3b8b0250..419e155af7 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -120,14 +120,6 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`) } - /** - * Fail-loud stub; supply `uploadFile` on the fixture's session face to exercise it. - * @returns never — always throws. - */ - uploadFile(): never { - throw new Error(`test session "${this.sessionId}": uploadFile is not stubbed — supply it on the fixture's session face`) - } - /** * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it. * @returns never — always throws. diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index db03ef8f0a..d16175f9b5 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -428,7 +428,6 @@ describe('fixture session face', () => { const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) expect(() => bare.readAttachment('att-1' as Parameters[0])).toThrow(/readAttachment is not stubbed/) - expect(() => bare.uploadFile()).toThrow(/uploadFile is not stubbed/) expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 203685b728..258d28029a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -705,6 +705,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -1740,6 +1743,33 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto packages/client/hmr: dependencies: @@ -2212,6 +2242,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../file-upload '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c52a2ce068..732512cd55 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -75,6 +75,7 @@ export const SERVICE_PAGE: Record = { deepseekLlmApiExtensions: 'llm-streaming.md', dynamicCordisRunner: 'extensions.md', e2b: 'subprocess.md', + fileUploads: 'attachment.md', fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', @@ -342,9 +343,11 @@ export const LINK_MAP: Readonly> = { SessionSelectModelValue: 'session.md', SessionSummary: 'session.md', SessionUpdateQueueRequest: 'session.md', - SessionUploadFileRequest: 'session.md', - SessionUploadFileValue: 'session.md', SessionUpdateQueueValue: 'session.md', + EncodedFileUploadRequest: 'attachment.md', + AgentResolver: 'attachment.md', + FileUploadReceiptId: 'attachment.md', + FileUploadValue: 'attachment.md', SessionStartSource: 'core.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', diff --git a/tsconfig.client.json b/tsconfig.client.json index 6675d98f44..c186cfbc47 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -54,7 +54,7 @@ { "path": "./packages/experimental/inspector/tsconfig.client.json" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, - { "path": "./packages/client/file-upload" }, + { "path": "./packages/client/file-upload/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index f7557c4d9a..b69a5d659e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -143,6 +143,7 @@ { "path": "./packages/util/atomic-write" }, { "path": "./packages/attachment/attachment" }, { "path": "./packages/attachment/attachment-local" }, + { "path": "./packages/client/file-upload/tsconfig.host.json" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/deepseek-llm-api-extensions" }, { "path": "./packages/llm/token-meter" }, From dd37061c2b4b5491d607f5a16ed6bb8540d39a11 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:20:08 +0800 Subject: [PATCH 17/37] test(client): provide file upload fixture service --- .../client/conversation-nodes/compaction.ts | 3 +-- .../tests/apply-inject.client.spec.tsx | 16 +++++------- .../service-orchestration.client.spec.ts | 26 ++++++------------- .../src/client/api-catalog.ts | 8 ++---- .../client-runtime/README.i18n.yaml | 4 +-- .../test-support/client-runtime/README.md | 6 ++--- .../test-support/client-runtime/README.zh.md | 6 ++--- .../test-support/client-runtime/src/index.ts | 17 +++++++++++- .../tests/helpers.client.spec.tsx | 7 +++++ 9 files changed, 49 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 5874c7bd61..9612f08741 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,9 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 6bb0a6a6ed..846cb5067d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -42,15 +42,13 @@ async function bench() { }, })) const uploads = new Map Promise>([[ROOT, rootUpload]]) - runtime.ctx.provide('fileUpload', { - available: true, - upload: (owner: Context, ...args: unknown[]) => { - const id = runtime.sessions.scopeOf(owner) - const upload = id === undefined ? undefined : uploads.get(id) - if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture') - return upload(...args) - }, - } as never) + runtime.fileUpload.available = true + runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => { + const id = runtime.sessions.scopeOf(owner) + const upload = id === undefined ? undefined : uploads.get(id) + if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture') + return upload(...args) + } runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index a04cf4c3f1..a78feb2e51 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -3,7 +3,7 @@ // TestSessions mints tagged scopes through the production createScope, so the // service's scopeOf/binding path runs against production resolution (no local // tag probe). -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { @@ -14,26 +14,16 @@ import { InputHub } from '../src/client/input/hub.ts' import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' -/** Test upload service that preserves each fixture Session's local upload override. */ -class TestFileUpload extends Service { - constructor(ctx: Context) { - super(ctx, 'fileUpload') - } - - upload(owner: Context, ...args: unknown[]): Promise { - const sessions = this.ctx.get('sessions') as { - sessionOf(ctx: Context): object | undefined - } - const session = sessions.sessionOf(owner) as { uploadFile?: (...input: unknown[]) => Promise } | undefined +async function bench(maxConcurrentFileUploads = 2) { + const runtime = await SlotTestRuntime.create() + runtime.fileUpload.available = true + runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => { + const session = runtime.sessions.sessionOf(owner) as { + uploadFile?: (...input: unknown[]) => Promise + } | undefined if (session?.uploadFile === undefined) throw new Error('test file upload has no Session override') return session.uploadFile(...args) } -} - -async function bench(maxConcurrentFileUploads = 2) { - const runtime = await SlotTestRuntime.create() - const uploadFiber = runtime.ctx.plugin(TestFileUpload) - await uploadFiber.await() const prompt = vi.fn(( _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 1e08d7b349..4bfd2c0db5 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -533,10 +533,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EntryKeyOf', declaration: 'export type EntryKeyOf = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;', }, - { - name: 'FileUploadReceiptId', - declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', - }, { name: 'GlobalStandardProps', declaration: 'export interface GlobalStandardProps {\n}', @@ -563,7 +559,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n uploadFile(data: Blob | Uint8Array | ReadableStream, name?: string, signal?: AbortSignal, onProgress?: (progress: {\n readonly loaded: number;\n readonly total?: number;\n }) => void): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyedHooksSources', @@ -663,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptContentPart', - declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n} | {\n readonly type: \'file\';\n readonly receiptId: FileUploadReceiptId;\n};', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n} | {\n readonly type: \'file\';\n readonly receiptId: Branded<\'file-upload-receipt-id\'>;\n};', }, { name: 'PromptError', diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index e3a08dda63..4437930d49 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -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/test-support/client-runtime/README.md -README.md: 3cc916343c25144720bfd599b87da7b5a2bb6b5d -README.zh.md: cd8a935f16bee9946edad8df41dab37c3b72af69 +README.md: 4ab70ecb6db7ae7d1844d8141b34daf7bd19a3dc +README.zh.md: c3265d7cdcadaf55c0744b13267ac5e64d6b47a6 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 3cc916343c..4ab70ecb6d 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-test-runtime` gives a browser feature spec a real jsdom test bench: it assembles a Cordis context, the renderer-owned slot registry, and the production `UiSession` adapter around typed Session and Workspace Controller doubles. Feature suites exercise declaration, registration, scoping, stores, injection, rendering, updates, and disposal without copying production renderer or adapter logic. Suites publish Session lifecycle state, Workspace state, projection values, and Conversation events through typed fixtures, then use local DOM snapshot roots, scoped Testing Library queries, and fail-loud service checks. It is not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. +`dsh-client-test-runtime` gives a browser feature spec a real jsdom test bench: it assembles a Cordis context, the renderer-owned slot registry, and the production `UiSession` adapter around typed Session and Workspace Controller doubles. A default file-upload stub satisfies features that declare the service and rejects if a test starts an upload without replacing it. Feature suites exercise declaration, registration, scoping, stores, injection, rendering, updates, and disposal without copying production renderer or adapter logic. Suites publish Session lifecycle state, Workspace state, projection values, and Conversation events through typed fixtures, then use local DOM snapshot roots, scoped Testing Library queries, and fail-loud service checks. It is not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. ## Table of Contents @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides an unavailable `fileUpload` stub so assemblies can mount; replace `runtime.fileUpload.upload` before mounting when a test exercises upload behavior. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. ### Local DOM snapshots @@ -101,7 +101,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p ### Lifecycle -`create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves. +`create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles plus the fail-loud file-upload stub. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index cd8a935f16..c3265d7cdc 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-test-runtime` 让浏览器功能测试拥有真实的 jsdom 测试台:它把 Cordis 上下文、渲染器拥有的 slot 注册表与生产 `UiSession` 适配器组装在带类型的 Session 和 Workspace Controller 替身周围。功能套件无需复制生产渲染器或适配器逻辑,即可检验声明、注册、作用域、store、注入、渲染、更新与销毁。套件通过带类型 fixture 发布 Session 生命周期状态、Workspace 状态、projection 值与 Conversation 事件,再使用局部 DOM 快照根、限定范围的 Testing Library 查询与自明的服务缺失检查。它不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 +`dsh-client-test-runtime` 让浏览器功能测试拥有真实的 jsdom 测试台:它把 Cordis 上下文、渲染器拥有的 slot 注册表与生产 `UiSession` 适配器组装在带类型的 Session 和 Workspace Controller 替身周围。默认文件上传替身可满足声明该服务的功能;测试若没有替换它却发起上传,就会明确失败。功能套件无需复制生产渲染器或适配器逻辑,即可检验声明、注册、作用域、store、注入、渲染、更新与销毁。套件通过带类型 fixture 发布 Session 生命周期状态、Workspace 状态、projection 值与 Conversation 事件,再使用局部 DOM 快照根、限定范围的 Testing Library 查询与自明的服务缺失检查。它不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 ## 目录 @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 +`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时会提供不可用的 `fileUpload` 替身,使装配可以挂载;测试上传行为时,需要在挂载前替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 ### 局部 DOM 快照 @@ -101,7 +101,7 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ### 生命周期 -`create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose feature fiber、释放根注册、dispose 已铸 session scope 并清除持久化 store 状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。 +`create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身和明确失败的文件上传替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose feature fiber、释放根注册、dispose 已铸 session scope 并清除持久化 store 状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。 diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 60c5c078eb..49e5b0056e 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -1,7 +1,7 @@ /** * jsdom slot test runtime: a real small runtime — Cordis `Context`, the * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around - * test-owned session/workspace doubles, so feature specs exercise + * test-owned session/workspace doubles and a fail-loud file-upload stub, so feature specs exercise * declaration, registration, scope, store, inject, rendering, updates, and * disposal without hand-building the machinery per suite. * @@ -110,6 +110,14 @@ export interface FeatureHandle { dispose(): Promise } +/** Mutable fail-loud file-upload stub installed by {@link SlotTestRuntime}. */ +export interface TestFileUpload { + /** Availability reported to the feature under test. */ + available: boolean + /** Test-supplied upload behavior; the default rejects every call. */ + upload: (owner: Context, ...args: unknown[]) => Promise +} + /** * Owner-props cell behind the auto frame: one external store the frame * subscribes to, so {@link SlotTestRuntime.renderSlot} and @@ -208,6 +216,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */ + readonly fileUpload: TestFileUpload private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -229,8 +239,13 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) + this.fileUpload = { + available: false, + upload: () => Promise.reject(new Error('client test runtime: file upload is not stubbed')), + } ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + ctx.provide('fileUpload', this.fileUpload as never) this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 2fb223150a..f4ee898406 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -38,6 +38,13 @@ function entry(seq: number): SessionLiveEventEntry { } describe('fixture helpers', () => { + it('rejects an upload until a suite replaces the default stub', async () => { + const runtime = await SlotTestRuntime.create() + expect(runtime.fileUpload.available).toBe(false) + await expect(runtime.fileUpload.upload(runtime.ctx)).rejects.toThrow('file upload is not stubbed') + await runtime.dispose() + }) + it('builds independent Conversation and Chat snapshots with optional overrides', () => { const conversation = conversationSnapshot() expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT) From 9fd72003e61093af519043d21bbb36ecd054cc07 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:20:23 +0800 Subject: [PATCH 18/37] chore(test): remove obsolete lint suppression --- packages/test-support/client-runtime/src/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 49e5b0056e..dc42c604b8 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -10,10 +10,6 @@ * machinery — everything mounts the production implementations. * @module @deepseek-ai/dsh-client-test-runtime */ -/* oxlint-disable typescript/no-redundant-type-constituents -- - * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots): - * this compilation unit sees only the runtime's 'root' row, but consumer - * programs merge their own keys in; the rule fires on the narrow-map view. */ import { Context, Inject } from '@deepseek-ai/cordis' import type { Fiber, Plugin } from '@deepseek-ai/cordis' import { createElement, Fragment, useSyncExternalStore } from 'react' From 71582276509852f112635a226acccaa409407b40 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:35:50 +0800 Subject: [PATCH 19/37] fix(file-upload): declare host runtime dependencies --- docs/capability-seams.i18n.yaml | 4 +-- docs/capability-seams.md | 5 +++ docs/capability-seams.zh.md | 5 +++ docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 6 ++-- docs/config-catalog.zh.md | 6 ++-- docs/event-producer-consumer.i18n.yaml | 4 +-- docs/event-producer-consumer.md | 14 ++++---- docs/event-producer-consumer.zh.md | 14 ++++---- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 6 ++-- docs/module-graph.zh.md | 6 ++-- packages/api/remotes/tests/built-lib.e2e.ts | 2 ++ .../tests/commands-upload-file.host.spec.ts | 12 +++---- .../attachment/attachment/README.i18n.yaml | 4 +-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/error.ts | 36 +++++++++++++------ packages/attachment/attachment/src/index.ts | 2 +- .../attachment/attachment/tests/index.spec.ts | 12 +++++++ packages/client/file-upload/package.json | 22 +++++------- packages/client/file-upload/src/index.ts | 8 ++--- .../tests/file-upload.client.spec.ts | 2 +- .../test-support/client-runtime/src/index.ts | 4 +++ pnpm-lock.yaml | 23 +++++++----- scripts/gen-doc-graphs.ts | 8 +++++ scripts/package-dependency-policy.ts | 2 ++ 27 files changed, 140 insertions(+), 79 deletions(-) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 795a426436..1b3b1a5d5c 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: 83868afe952c5dbc179114ff52228fc1dc8b8fdb -capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029 +capability-seams.md: ecc88e397f41c4d279effa2df9d1b29108756ea5 +capability-seams.zh.md: 105752ac01007d24788bcfbcb2eb22c07cfdd0f5 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 83868afe95..ecc88e397f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -14,6 +14,8 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] + pkg_client_file_upload["client-file-upload"] + svc_fileUploads["ctx.fileUploads
Agent-scoped staged file uploads"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_replay["llm-replay"] @@ -235,6 +237,7 @@ flowchart LR pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell + pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime @@ -364,6 +367,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fileReferences --> pkg_api_session_controller + svc_fileUploads --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -466,6 +470,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | +| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 064797bbc3..105752ac01 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -16,6 +16,8 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] + pkg_client_file_upload["client-file-upload"] + svc_fileUploads["ctx.fileUploads
Agent-scoped staged file uploads"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_replay["llm-replay"] @@ -237,6 +239,7 @@ flowchart LR pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell + pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime @@ -366,6 +369,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fileReferences --> pkg_api_session_controller + svc_fileUploads --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -468,6 +472,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | +| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 239a58feec..d964cdbb6e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 6d86df7b879e8ac23aebf300a8fdfec6b7459b6e -config-catalog.zh.md: 2e5bb999254d73b3e4589bad98104bba21d14383 +config-catalog.md: 915b5f4f00a7dd8e5c002c0e94f8669a45409a41 +config-catalog.zh.md: bce599214882db6168923f9b76ec8fca1ae41b88 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6d86df7b87..915b5f4f00 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,7 +203,7 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in ## `@deepseek-ai/dsh-api-session-controller` -Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -217,7 +217,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:75`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:73`](../packages/api/session-controller/src/index.ts) @@ -3337,7 +3337,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) -- `@deepseek-ai/dsh-client-file-upload` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 2e5bb99925..bce5992148 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,7 +205,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -219,7 +219,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:73`](../packages/api/session-controller/src/index.ts) @@ -3339,7 +3339,7 @@ export interface Config { - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) -- `@deepseek-ai/dsh-client-file-upload`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index c9ca62f28e..4f7cc9457d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 3f0d11a4d8fcf287413bb5ecc4b4f4f722bf195f -event-producer-consumer.zh.md: b5314f71351026de9802f4839b837fb7fb74a4fd +event-producer-consumer.md: bf6d183b32ee11110b79a923c29f8fb02ad75442 +event-producer-consumer.zh.md: dbb396290152e3585a23d7b71283de53608f4be7 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3f0d11a4d8..bf6d183b32 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:546`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:573`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:559`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:547`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:527`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:554`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:533`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:540`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -46,8 +46,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b5314f7135..dbb3962901 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:546`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:573`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:559`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:547`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:527`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:554`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:533`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:540`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -48,8 +48,8 @@ | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index da03991c6b..c04e3d9215 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 44569324d9e2936da8a21bdc4e81b2bec0d2b553 -module-graph.zh.md: e4ae699e3681761e4fb4ffc806edfac994008f13 +module-graph.md: 550ee35bcb985d8731f8b3fe37e7b9585b88a47b +module-graph.zh.md: 118ec8e5ea22112b2b0ec3586e875a208b4ec2b6 diff --git a/docs/module-graph.md b/docs/module-graph.md index 44569324d9..550ee35bcb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -404,6 +404,7 @@ flowchart TD pkg_api_remotes --> pkg_scope pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm @@ -1047,6 +1048,7 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs @@ -1168,7 +1170,6 @@ flowchart TD | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | | [`client-connection`](../packages/client/connection) | `client` | — | -| [`client-file-upload`](../packages/client/file-upload) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1255,6 +1256,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1384,7 +1386,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e4ae699e36..118ec8e5ea 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -406,6 +406,7 @@ flowchart TD pkg_api_remotes --> pkg_scope pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm @@ -1049,6 +1050,7 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs @@ -1170,7 +1172,6 @@ flowchart TD | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | | [`client-connection`](../packages/client/connection) | `client` | — | -| [`client-file-upload`](../packages/client/file-upload) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1257,6 +1258,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1386,7 +1388,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 352178c781..62a740558b 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -48,6 +48,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const script = ` import { createServer } from 'node:http' import * as cordis from '@deepseek-ai/cordis' + import * as zod from 'zod' const urls = ${JSON.stringify(urls)} const { Context } = cordis @@ -157,6 +158,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) return handoff.factory(specifier => { if (specifier === '@deepseek-ai/cordis') return cordis + if (specifier === 'zod') return zod throw new Error('unexpected Client external ' + specifier) }) } diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 66e5e55302..4de1aaca30 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -248,13 +248,13 @@ describe('Session file uploads', () => { it('retires accepted receipts after their rpcId becomes observable', async () => { const { controller, uploads, agent } = await uploadHarness() - uploads.retirePrompt(agent, 'not-staged' as SessionRequestId) + uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) - uploads.retirePrompt(agent, 'other-request' as SessionRequestId) + uploads.retirePrompt(agent, 'other-request') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) - uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -337,7 +337,7 @@ describe('Session file uploads', () => { requestId: 'req-2' as SessionRequestId, }) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) - uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + uploads.retirePrompt(agent, 'req-1') admitted.resolve([{ attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, }]) @@ -355,7 +355,7 @@ describe('Session file uploads', () => { ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), requestId: 'req-2' as SessionRequestId, })).rejects.toMatchObject({ code: 'session/agent-busy' }) - uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -366,7 +366,7 @@ describe('Session file uploads', () => { await expect(controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) - uploads.retirePrompt(agent, 'req-1' as SessionRequestId) + uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) }) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index c57edf82cf..13a3d80930 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: f815cfa7ea070f28efa3d5f049962b8837072b5d -README.zh.md: eb9275ef63f2d581a0c0b867e1b2e40992391797 +README.md: eab3be50e9dccb2b623336aa96982799f2981299 +README.zh.md: 9f7d6726bc18a7390d20e26ea7d0616949ecbf44 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index f815cfa7ea..eab3be50e9 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. -- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers route on `code`, never on the prototype chain. +- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index eb9275ef63..9f7d6726bc 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象都会失败关闭。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 -- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方按 `code` 路由,绝不依赖原型链。 +- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装把它对每条路由都投影成确定性 handle 文本。 ### 服务操作 diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 7a1588b55e..0aed793f35 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -15,20 +15,24 @@ const IMAGE_ADMISSION_ERROR_CODES = [ /** Caller-correctable attachment failure codes raised while admitting image input. */ export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number] +const ATTACHMENT_ERROR_CODES = [ + ...IMAGE_ADMISSION_ERROR_CODES, + 'INVALID_FILE_BASE64', + 'INVALID_ATTACHMENT_REF', + 'ATTACHMENT_CORRUPT', + 'ATTACHMENT_WRITE_FAILED', + 'ATTACHMENT_NOT_FOUND', + 'ATTACHMENT_READ_FAILED', + 'ATTACHMENT_PROJECTION_UNSUPPORTED', + 'ATTACHMENT_FILES_UNSUPPORTED', +] as const + /** Stable attachment failure codes used for protocol error routing. */ -export type AttachmentErrorCode = - | ImageAdmissionErrorCode - | 'INVALID_FILE_BASE64' - | 'INVALID_ATTACHMENT_REF' - | 'ATTACHMENT_CORRUPT' - | 'ATTACHMENT_WRITE_FAILED' - | 'ATTACHMENT_NOT_FOUND' - | 'ATTACHMENT_READ_FAILED' - | 'ATTACHMENT_PROJECTION_UNSUPPORTED' - | 'ATTACHMENT_FILES_UNSUPPORTED' +export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number] /** Runtime membership for structurally compatible errors crossing package boundaries. */ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) +const ATTACHMENT_ERROR_CODE_SET: ReadonlySet = new Set(ATTACHMENT_ERROR_CODES) /** * Stable failures suitable for host RPC error mapping. @@ -55,6 +59,18 @@ export class AttachmentError extends Error { } } +/** + * Identify attachment failures by their stable code across duplicate package installations. + * @param error - failure raised while validating, persisting, or reading an attachment. + * @returns whether the failure carries a recognized attachment error code. + */ +export function isAttachmentError(error: unknown): error is AttachmentError { + return error instanceof Error + && 'code' in error + && typeof error.code === 'string' + && ATTACHMENT_ERROR_CODE_SET.has(error.code) +} + /** * Distinguish caller-correctable image admission failures from storage faults. * @param error - failure raised while validating or persisting an image batch. diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8ae640c947..5bff532ac8 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -15,7 +15,7 @@ import type { } from './types.ts' export { AttachmentId, ImageVariantId } from './brand.ts' -export { AttachmentError, isImageAdmissionError } from './error.ts' +export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedFile, admitEncodedImages, admitPromptContent } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index fcccd60b6e..d645d35e70 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -4,6 +4,7 @@ import AttachmentStore, { AttachmentError, AttachmentId, ImageVariantId, + isAttachmentError, isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, @@ -188,3 +189,14 @@ describe('isImageAdmissionError', () => { expect(isImageAdmissionError(new Error('unknown failure'))).toBe(false) }) }) + +describe('isAttachmentError', () => { + it('recognizes attachment failures from another package installation by code', () => { + expect(isAttachmentError(new AttachmentError('bad base64', 'INVALID_FILE_BASE64'))).toBe(true) + expect(isAttachmentError(Object.assign(new Error('foreign storage error'), { + code: 'ATTACHMENT_WRITE_FAILED', + }))).toBe(true) + expect(isAttachmentError(Object.assign(new Error('other failure'), { code: 'OTHER' }))).toBe(false) + expect(isAttachmentError({ code: 'ATTACHMENT_WRITE_FAILED' })).toBe(false) + }) +}) diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 3d098ab6b5..62d760912e 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -61,28 +61,24 @@ "lib/typert.remote-client.js", "lib/typert.remote-client.d.ts" ], + "dependencies": { + "zod": "^4.4.3", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^" + "@deepseek-ai/dsh-scope": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^" + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^" } } diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 29d318c099..4cd257f7e0 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -3,9 +3,9 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitEncodedFile } from '@deepseek-ai/dsh-attachment' +import { admitEncodedFile, isAttachmentError } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-client-connection' import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' import { scopeOf } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' @@ -57,7 +57,7 @@ export class FileUploads extends TypertRemoteService { 'file-upload: command file receipt resolver', ) ctx.effect( - () => (ctx.connection as HostConnectionHandle).fetch.register({ + () => ctx.connection.fetch.register({ path: FILE_UPLOAD_PATH, methods: ['POST'], requestBody: 'streaming', @@ -168,7 +168,7 @@ export class FileUploads extends TypertRemoteService { try { file = await save() } catch (error) { - if (error instanceof AttachmentError) { + if (isAttachmentError(error)) { throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never) } throw new RemoteError( diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 8b80d85d48..ceeff40e14 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -390,7 +390,7 @@ describe('Agent-scoped file upload', () => { ctx.provide('typert', { contexts: { getClient: (kind: string) => kind === 'agent' - ? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') } + ? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined } : undefined, }, } as never) diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index dc42c604b8..49e5b0056e 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -10,6 +10,10 @@ * machinery — everything mounts the production implementations. * @module @deepseek-ai/dsh-client-test-runtime */ +/* oxlint-disable typescript/no-redundant-type-constituents -- + * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots): + * this compilation unit sees only the runtime's 'root' row, but consumer + * programs merge their own keys in; the rule fires on the narrow-map view. */ import { Context, Inject } from '@deepseek-ai/cordis' import type { Fiber, Plugin } from '@deepseek-ai/cordis' import { createElement, Fragment, useSyncExternalStore } from 'react' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 258d28029a..ce67a8f4a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1739,6 +1739,19 @@ importers: version: link:../../util/values packages/client/file-upload: + dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1746,9 +1759,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-attachment': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../../attachment/attachment + version: link:../../api/remotes '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -1761,12 +1774,6 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 9289676d87..cd7a5c4575 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -107,6 +107,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', }, + { + key: 'fileUploads', + pkg: 'client-file-upload', + title: 'Agent-scoped staged file uploads', + mode: 'core', + consumers: ['api-session-controller'], + note: 'Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions.', + }, { key: 'llm', pkg: 'llm', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 11702c568a..ab9cbf6faf 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -41,9 +41,11 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ * Runtime exports whose values remain valid when npm installs another package copy. */ const SAFE_HOST_DEPENDENCY_EXPORTS = { + '@deepseek-ai/dsh-attachment': ['admitEncodedFile', 'isAttachmentError'], '@deepseek-ai/dsh-credentials': ['credentialKey'], '@deepseek-ai/dsh-deque': ['Deque'], '@deepseek-ai/dsh-llm': ['callConfigEquals'], + '@deepseek-ai/dsh-session': ['SessionId'], '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'], '@deepseek-ai/schemastery': ['default'], } as const satisfies HostDependencyExports From 3b8245afc392f9f862ab5ecdacd6ef5d85d0007c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:41:45 +0800 Subject: [PATCH 20/37] fix(build): align file upload project faces --- packages/api/remotes/tsconfig.client.json | 2 +- .../client/file-upload/src/client/contract.ts | 16 +++------------- packages/client/file-upload/src/types.ts | 13 +++++++++++++ .../webworker-runtime/src/client/index.ts | 2 +- .../experimental/webworker-runtime/tsconfig.json | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index da348fa6c2..2be76de65a 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -21,7 +21,7 @@ "path": "../../client/connection/tsconfig.client.json" }, { - "path": "../../client/file-upload/tsconfig.host.json" + "path": "../../client/file-upload/tsconfig.client.json" }, { "path": "../../credentials/credentials" diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 936cb21b38..407384f69a 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -1,3 +1,5 @@ +import type { Context } from '@deepseek-ai/cordis' + /** Browser request body accepted by the background file-upload service. */ export type FileUploadBody = Blob | ReadableStream @@ -32,16 +34,4 @@ export interface FileUploadService { >> } -/** - * Fetch-shaped carrier installed by a page that owns its Host transport. - * @param input - absolute same-origin upload URL. - * @param init - raw request body, headers, and cancellation signal. - * @returns the Host response. - */ -export type FileUploadFetch = (input: URL, init: RequestInit) => Promise - -/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ -export interface ClientFileUploadHooks { - readonly fetch: FileUploadFetch -} -import type { Context } from '@deepseek-ai/cordis' +export type { ClientFileUploadHooks, FileUploadFetch } from '../types.ts' diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts index d1f750bd56..b63b2452bf 100644 --- a/packages/client/file-upload/src/types.ts +++ b/packages/client/file-upload/src/types.ts @@ -20,3 +20,16 @@ export interface FileUploadValue { /** Host-minted authority for one staged file upload in one Agent scope. */ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> + +/** + * Fetch-shaped carrier installed by a page that owns its Host transport. + * @param input - absolute same-origin upload URL. + * @param init - raw request body, headers, and cancellation signal. + * @returns the Host response. + */ +export type FileUploadFetch = (input: URL, init: RequestInit) => Promise + +/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ +export interface ClientFileUploadHooks { + readonly fetch: FileUploadFetch +} diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 137776853c..467f915902 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client */ import { IMAGE_FILE_NAME } from '../image-layout.ts' -import type { ClientFileUploadHooks } from '@deepseek-ai/dsh-client-file-upload/client' +import type { ClientFileUploadHooks } from '@deepseek-ai/dsh-client-file-upload/types' import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index 14491067c7..6e76c8b1fc 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -30,7 +30,7 @@ "path": "../../client/connection/tsconfig.host.json" }, { - "path": "../../client/file-upload/tsconfig.client.json" + "path": "../../client/file-upload/tsconfig.host.json" }, { "path": "../../host/webserver" From 33b7123e96197edee831ae49888fa6dff0030427 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:49:08 +0800 Subject: [PATCH 21/37] test(remotes): declare built smoke schema dependency --- packages/api/remotes/package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 51a7fe0277..e3ce7a9c78 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce67a8f4a3..2b3d9d025a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -753,6 +753,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../interaction/user-questions + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/api/session-controller: dependencies: From 8a84c1edff8485e9b299b1c31f9841ba01a31742 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 17:03:14 +0800 Subject: [PATCH 22/37] test(file-upload): cover isolated service paths --- .../tests/commands-upload-file.host.spec.ts | 30 ++++++++++++++++++- .../tests/controller.host.spec.ts | 21 +++++++++++++ packages/client/file-upload/src/index.ts | 2 +- .../tests/file-upload.client.spec.ts | 29 ++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4de1aaca30..c3091f76b4 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -29,6 +29,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ saveFileStream: ReturnType saveImages: ReturnType disposeAgent: () => void + uploadRoute: (request: Request) => Promise }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -68,7 +69,15 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ const saveImages = vi.fn((): Promise => Promise.reject(new Error('fixture did not expect image persistence'))) ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never) - ctx.provide('connection', { fetch: { register: () => async () => {} } } as never) + let uploadRoute: ((request: Request) => Promise) | undefined + ctx.provide('connection', { + fetch: { + register: (route: { readonly fetch: (request: Request) => Promise }) => { + uploadRoute = route.fetch + return () => {} + }, + }, + } as never) ctx.provide('llm', { listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), @@ -83,6 +92,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), } as unknown as ApiSessionAgentController const uploads = new FileUploads(ctx) + if (uploadRoute === undefined) throw new Error('file upload route was not registered') return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), @@ -93,6 +103,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ saveFileStream, saveImages, disposeAgent, + uploadRoute, } } @@ -106,6 +117,12 @@ function promptRequest(content: Parameters[0 } describe('Session file uploads', () => { + it('registers an HTTP route bound to the upload service', async () => { + const { uploadRoute } = await uploadHarness() + await expect(uploadRoute(new Request('http://host/upload'))) + .resolves.toMatchObject({ status: 405 }) + }) + it('stages one verbatim upload and cites it from a later prompt as a file block', async () => { const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal) @@ -219,10 +236,21 @@ describe('Session file uploads', () => { disposeResolver() const replacement = vi.fn(async () => agent) const disposeReplacement = uploads.registerAgentResolver(replacement) + disposeResolver() + expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered') expect(disposeReplacement).toBeTypeOf('function') disposeReplacement() }) + it('rejects a cold upload when no Agent resolver is registered', async () => { + const { uploads, disposeAgent } = await uploadHarness() + disposeAgent() + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable {})(), + })).rejects.toMatchObject({ code: 'session/not-found' }) + }) + it('rejects subagent uploads and access outside the receiving Agent scope', async () => { const child = await uploadHarness('subagent') await expect(child.uploads.upload( diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 5a8ffb783b..a0b73df11f 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -42,6 +42,16 @@ describe('SessionController facade', () => { list: () => Promise.resolve([header]), inspect, }) as never) + let uploadResolver: ((sessionId: SessionId) => Promise) | undefined + ctx.provide('fileUploads', { + registerAgentResolver: (resolve: (sessionId: SessionId) => Promise) => { + uploadResolver = resolve + return () => {} + }, + resolve: () => undefined, + bindPrompt: () => () => {}, + retirePrompt: () => {}, + } as never) const controller = createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() @@ -65,6 +75,17 @@ describe('SessionController facade', () => { ctx, } as Agent ctx.agents.register(agent) + const resolveUploadAgent = (id: SessionId): Promise => { + if (uploadResolver === undefined) throw new Error('file upload resolver was not registered') + return uploadResolver(id) + } + await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent) + const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId }) + vi.spyOn( + (controller as unknown as { agents: ApiSessionAgentController }).agents, + 'resolveAgent', + ).mockResolvedValueOnce({ error: activationError }) + await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError) const consumeSelection = vi.spyOn( (controller as unknown as { agents: ApiSessionAgentController }).agents, 'consumeSelection', diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 4cd257f7e0..7a6a3d3b85 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -223,7 +223,7 @@ export class FileUploads extends TypertRemoteService { private observeSessionEvent(session: Session, event: SessionEvent): void { if (event.type !== 'user/message' || event.data.source.kind !== 'user' || !('rpcId' in event.data.source)) return - if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId) + this.retire(session, event.data.source.rpcId) } private retire(session: Session, requestId: string): void { diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index ceeff40e14..14a26c8dae 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -483,10 +483,22 @@ describe('Agent-scoped file upload', () => { await fixture.fiber.dispose() vi.stubGlobal('location', { origin: 'https://preview.test' }) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(null, { status: 413 })), + } + const rejected = await scopedService({ sessionId: 's1' }) + await expect(rejected.service.upload(rejected.owner, new Blob())) + .rejects.toThrow('file upload transport failed with HTTP 413') + await rejected.fiber.dispose() + const bodies: unknown[] = [ null, { ok: 'yes' }, { ok: false, error: null }, + { ok: false, error: { code: 1, message: 'denied', details: {} } }, + { ok: false, error: { code: 'denied', message: 1, details: {} } }, + { ok: false, error: { code: 'denied', message: 'denied', details: null } }, + { ok: true, value: null }, { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, ] for (const body of bodies) { @@ -498,5 +510,22 @@ describe('Agent-scoped file upload', () => { .rejects.toThrow(/file upload transport returned an invalid/) await malformed.fiber.dispose() } + + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }), { status: 200 })), + } + const failed = await scopedService({ sessionId: 's1' }) + await expect(failed.service.upload(failed.owner, new Blob())).resolves.toMatchObject({ + ok: false, + error: { + code: 'session/attachment-invalid', + message: 'denied', + details: { reason: 'NOPE' }, + }, + }) + await failed.fiber.dispose() }) }) From f989df7c8e13aba59bf8f4af64824a18f6a91673 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 17:04:29 +0800 Subject: [PATCH 23/37] test(file-upload): cover extensible request identifiers --- .../tests/commands-upload-file.host.spec.ts | 8 ++++++++ packages/client/file-upload/src/index.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index c3091f76b4..7991a7cee8 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -294,6 +294,14 @@ describe('Session file uploads', () => { new AbortController().signal, ) await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + first.ctx.emit('session/event', first.agent.session, { + type: 'user/message', + data: createUserMessage({ + content: [{ type: 'text', text: 'extension event' }], + source: { kind: 'user', rpcId: 1 } as never, + }), + } as never) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) first.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'observed' }], source: { kind: 'user', rpcId: 'req-1' as SessionRequestId }, diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 7a6a3d3b85..4cd257f7e0 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -223,7 +223,7 @@ export class FileUploads extends TypertRemoteService { private observeSessionEvent(session: Session, event: SessionEvent): void { if (event.type !== 'user/message' || event.data.source.kind !== 'user' || !('rpcId' in event.data.source)) return - this.retire(session, event.data.source.rpcId) + if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId) } private retire(session: Session, requestId: string): void { From b2c4483c9d327b533a421816bdb06f0c80349bbd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 18:18:32 +0800 Subject: [PATCH 24/37] refactor(attachment): own prompt admission on service --- docs/subsystems/attachment.i18n.yaml | 4 +-- docs/subsystems/attachment.md | 35 ++++++++++++++++++- docs/subsystems/attachment.zh.md | 35 ++++++++++++++++++- .../api/session-controller/src/commands.ts | 5 ++- .../tests/commands-upload-file.host.spec.ts | 7 ++-- .../tests/session-models.host.spec.ts | 4 +-- .../attachment/attachment/README.i18n.yaml | 4 +-- packages/attachment/attachment/README.md | 4 +-- packages/attachment/attachment/README.zh.md | 4 +-- .../attachment/attachment/src/admission.ts | 25 ------------- packages/attachment/attachment/src/index.ts | 25 ++++++++++++- packages/attachment/attachment/src/types.ts | 2 +- .../attachment/tests/admission.spec.ts | 18 +++++----- .../extensions/tool-cordis/src/api-catalog.ts | 11 ++++++ packages/subagent/subagent/src/index.ts | 4 +-- .../subagent/subagent/tests/control.spec.ts | 10 +++--- scripts/gen-cordis-catalog.ts | 2 ++ scripts/type-equiv.manifest.json | 10 ++++++ 18 files changed, 150 insertions(+), 59 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index a60f9ab92d..6b849c3679 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 19c659508936552a449b7b4ee9a085f015ed8c14 -attachment.zh.md: 9942c7a9d2d7b37e10c946c10297cbdada0936c2 +attachment.md: 3fe72b70af27a7597bca8eed24070589c1ba64d7 +attachment.zh.md: db43180565baaf0512cd5dc261324c79dddd1552 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 19c6595089..3fe72b70af 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -62,6 +62,30 @@ The reference records intrinsic dimensions and encoded length so clients can lay ## Commit and verified-read payloads +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +149,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` is the Host prompt entry and replaces base64 image uploads with durable references in part order. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -157,6 +181,15 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access attachment storage. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly PromptContentPart[], ): Promise + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 9942c7a9d2..db43180565 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -62,6 +62,30 @@ interface ImageAttachmentLimits { ## 提交与经校验读取的数据 +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +149,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 是 Host prompt 入口,按 part 顺序把 base64 图片上传替换为持久引用。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -157,6 +181,15 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access attachment storage. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly PromptContentPart[], ): Promise + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 01afde33e3..2471f0990a 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' @@ -543,8 +543,7 @@ async function durablePromptContent( files.set(part.receiptId, file) } type NonFilePart = Exclude - const admitted = await admitPromptContent( - ctx.attachments, + const admitted = await ctx.attachments.admitPromptContent( content.filter((part): part is NonFilePart => part.type !== 'file'), ) let next = 0 diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 7991a7cee8..acff794b8a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, } from '@deepseek-ai/dsh-attachment' @@ -68,7 +68,10 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }) const saveImages = vi.fn((): Promise => Promise.reject(new Error('fixture did not expect image persistence'))) - ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf( + { saveFile, saveFileStream, saveImages }, + AttachmentStore.prototype, + ) as never) let uploadRoute: ((request: Request) => Promise) | undefined ctx.provide('connection', { fetch: { diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index cd26dfb39d..9fe2bd28bb 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -665,7 +665,7 @@ describe('Web session model selection', () => { const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, } - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: () => { if (saveMode === 'error') return Promise.reject(new Error('image store offline')) if (saveMode === 'remote') { @@ -673,7 +673,7 @@ describe('Web session model selection', () => { } return Promise.resolve([savedRef]) }, - } as never) + }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) const remote = createSessionTestRemote(ctx, { diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 13a3d80930..2aceef5333 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: eab3be50e9dccb2b623336aa96982799f2981299 -README.zh.md: 9f7d6726bc18a7390d20e26ea7d0616949ecbf44 +README.md: 1ab340bbd96881aceaa9ba20ace02fecb2f85052 +README.zh.md: 1fbc9163f202dac32f48d0ca39aa4ebe9559766f diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index eab3be50e9..1ab340bbd9 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers call `ctx.attachments.admitPromptContent()` to replace browser image uploads with durable references while retaining part order. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map @@ -80,7 +80,7 @@ The service family runs one admission-and-storage flow: every entry point enforc |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | -| [`src/admission.ts`](src/admission.ts) | Browser prompt and file admission: canonical-base64 enforcement, store delegation, and durable image-part projection | +| [`src/admission.ts`](src/admission.ts) | Canonical-base64 enforcement and store delegation for encoded image and file uploads | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | — | No runtime invariant companion is published; this stateless seam owns types while implementations enforce immutable-store checks. | diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 9f7d6726bc..1fbc9163f2 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方调用 `ctx.attachments.admitPromptContent()`,按原顺序把浏览器图片上传替换为持久引用。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 @@ -80,7 +80,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | -| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 与文件准入:强制规范 base64、委托存储并投影持久图片 part | +| [`src/admission.ts`](src/admission.ts) | 对编码图片和文件上传强制执行规范 base64 并委托存储 | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 | diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts index a3f5f16c8c..8a5b5ab41b 100644 --- a/packages/attachment/attachment/src/admission.ts +++ b/packages/attachment/attachment/src/admission.ts @@ -4,12 +4,10 @@ import { Buffer } from 'node:buffer' import { AttachmentError } from './error.ts' import type { AttachmentStore } from './index.ts' import type { - AdmittedPromptContentPart, EncodedFileAttachment, EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef, - PromptContentPart, SaveImageAttachment, } from './types.ts' @@ -74,26 +72,3 @@ export async function admitEncodedFile( ...file.name === undefined ? {} : { name: file.name }, }) } - -/** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access the attachment store. - * @param attachments - the deployment attachment store owning batch policy. - * @param content - browser prompt parts in message order. - * @returns admitted prompt parts in the same order as `content`. - * @throws AttachmentError when the image batch is refused. - */ -export async function admitPromptContent( - attachments: AttachmentStore, - content: readonly PromptContentPart[], -): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) -} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 5bff532ac8..1def0c6102 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,12 +1,15 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ import { Context, Service } from '@deepseek-ai/cordis' +import { admitEncodedImages } from './admission.ts' import { AttachmentError } from './error.ts' import type { + AdmittedPromptContentPart, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, + PromptContentPart, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -17,7 +20,7 @@ import type { export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' -export { admitEncodedFile, admitEncodedImages, admitPromptContent } from './admission.ts' +export { admitEncodedFile, admitEncodedImages } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, @@ -98,6 +101,26 @@ export abstract class AttachmentStore extends Service { return refs } + /** + * Admit one browser prompt and replace each uploaded image with its durable reference. + * Text-only prompts do not access attachment storage. + * @param content - browser prompt parts in message order. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ + async admitPromptContent( + content: readonly PromptContentPart[], + ): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) + } + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 03fb45c761..032c7e07c8 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -94,7 +94,7 @@ export interface EncodedImageAttachment { /** * Browser-submitted prompt content accepted by Host prompt endpoints; the * accepting Host promotes image parts to durable references through - * `admitPromptContent` before any message is created, so a wire caller can + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can * never cite an attachment it did not upload. */ export type PromptContentPart = diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 60eadd421f..7951164cd7 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,13 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { admitEncodedFile, admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { admitEncodedFile, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { - const store = { + const mocks = { saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({ attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'], mediaType: input.mediaType, @@ -17,7 +16,8 @@ function storeOf() { ...input.name === undefined ? {} : { name: input.name }, })))), } - return { store: store as unknown as AttachmentStore, mocks: store } + const store = Object.setPrototypeOf(mocks, AttachmentStore.prototype) as AttachmentStore + return { store, mocks } } describe('admitEncodedImages', () => { @@ -103,17 +103,19 @@ describe('admitEncodedFile', () => { }) }) -describe('admitPromptContent', () => { +describe('AttachmentStore.admitPromptContent', () => { it('converts text-only prompts without touching the attachment store', async () => { - const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } - await expect(admitPromptContent(store as unknown as AttachmentStore, [ + const store = Object.setPrototypeOf({ + saveImages: () => { throw new Error('text-only prompts must not reach the store') }, + }, AttachmentStore.prototype) as AttachmentStore + await expect(store.admitPromptContent([ { type: 'text', text: 'hello' }, ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) }) it('replaces image parts with admitted references in part order', async () => { const { store } = storeOf() - await expect(admitPromptContent(store, [ + await expect(store.admitPromptContent([ { type: 'image', mediaType: 'image/png', data: 'AQ==' }, { type: 'text', text: 'between' }, { type: 'image', mediaType: 'image/png', data: 'Ag==' }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1b7bcf71e2..60e7b54d9e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -482,6 +482,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], returns: 'durable normalized attachment references in the same order after every member succeeds.', }, + { + signature: 'async admitPromptContent( content: readonly PromptContentPart[], ): Promise', + description: 'Admit one browser prompt and replace each uploaded image with its durable reference. Text-only prompts do not access attachment storage.', + parameters: [{ name: 'content', description: 'browser prompt parts in message order.' }], + returns: 'admitted prompt parts in the same order as `content`.', + throws: ['AttachmentError when the image batch is refused.'], + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.', @@ -3439,6 +3446,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', }, + { + name: 'AdmittedPromptContentPart', + declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n};', + }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n}', diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 8eb29021ad..4c734db743 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -30,7 +30,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { admitPromptContent } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-attachment' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -446,7 +446,7 @@ export class SubagentRuntime extends TypertRemoteService { } else { const attachments = this.ctx.get('attachments') if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store') - content = await admitPromptContent(attachments, request.content) + content = await attachments.admitPromptContent(request.content) } return { messageId: await this[deliverSubagentPrompt]( diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 733564032b..22db177cc3 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime, { @@ -169,7 +169,7 @@ describe('subagent prompt Remote', () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) => inputs.map((input, index) => ({ ...IMAGE_REF, attachmentId: `att-${index}`, mediaType: input.mediaType }))) - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents).mockResolvedValue('m-content' as MessageId) const content = [ { type: 'text' as const, text: 'before' }, @@ -188,11 +188,11 @@ describe('subagent prompt Remote', () => { it('maps a refused image batch to subagent/attachment-invalid and delivers nothing', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: async () => { throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') }, - } as never) + }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ @@ -207,7 +207,7 @@ describe('subagent prompt Remote', () => { it('maps non-canonical base64 to subagent/attachment-invalid without touching the store', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn() - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 732512cd55..371d6e62cd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -357,6 +357,7 @@ export const LINK_MAP: Readonly> = { ApprovalRequestEvent: 'approval.md', ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', + AdmittedPromptContentPart: 'attachment.md', EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', FileAttachmentRef: 'attachment.md', @@ -365,6 +366,7 @@ export const LINK_MAP: Readonly> = { ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', + PromptContentPart: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', StoredImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a2ad93dd52..c0109a078e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -946,6 +946,16 @@ "symbol": "ImageAttachmentLimits", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "PromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AdmittedPromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "EncodedImageAttachment", From 3eb9736eb6eeebfdfcbc5fda5e9e1120ee15bf88 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 20:15:18 +0800 Subject: [PATCH 25/37] refactor(file-upload): scope prompt binding rollback --- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 7 ++-- docs/subsystems/attachment.zh.md | 7 ++-- .../api/session-controller/src/commands.ts | 12 ++---- .../tests/controller.host.spec.ts | 2 +- .../session-controller/tests/test-remote.ts | 2 +- packages/client/file-upload/README.i18n.yaml | 4 +- packages/client/file-upload/README.md | 2 +- packages/client/file-upload/README.zh.md | 2 +- packages/client/file-upload/src/index.ts | 37 ++++++++++++++++--- .../extensions/tool-cordis/src/api-catalog.ts | 10 +++-- scripts/gen-cordis-catalog.ts | 1 + 12 files changed, 60 insertions(+), 30 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 6b849c3679..f6bcc65c23 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 3fe72b70af27a7597bca8eed24070589c1ba64d7 -attachment.zh.md: db43180565baaf0512cd5dc261324c79dddd1552 +attachment.md: 6d9d4c86aa9435450a766c6aa74f4062824ba96b +attachment.zh.md: 4a8a7c3acf4203ed4d3cff2b92098086a5aa39a9 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 3fe72b70af..6d9d4c86aa 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -305,13 +305,14 @@ async uploadStream(request: { readonly sessionId: SessionId readonly data: Async resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined /** - * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. * @param agent - receiving Agent. * @param receiptIds - distinct staged receipts referenced by the prompt. * @param requestId - prompt identity later observed in queue or history. - * @returns rollback restoring every prior binding. + * @returns binding kept after commit until queue or history observation retires its receipts. */ -bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding /** * Retire every receipt accepted by one removed queue occurrence. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index db43180565..4a8a7c3acf 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -305,13 +305,14 @@ async uploadStream(request: { readonly sessionId: SessionId readonly data: Async resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined /** - * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. * @param agent - receiving Agent. * @param receiptIds - distinct staged receipts referenced by the prompt. * @param requestId - prompt identity later observed in queue or history. - * @returns rollback restoring every prior binding. + * @returns binding kept after commit until queue or history observation retires its receipts. */ -bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding /** * Retire every receipt accepted by one removed queue occurrence. diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 2471f0990a..8b2a4ad658 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -341,14 +341,10 @@ export class SessionCommandController { { sessionId: agent.id }, ) } - const rollback = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) - try { - if (request.mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error) { - rollback() - throw error - } + using binding = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) + if (request.mode === 'steer') agent.steer(message) + else agent.followup(message) + binding.commit() } catch (error) { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index a0b73df11f..422252ed8d 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -49,7 +49,7 @@ describe('SessionController facade', () => { return () => {} }, resolve: () => undefined, - bindPrompt: () => () => {}, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) const controller = createSessionTestController(ctx, defaults) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 531db817cb..ece2ad1512 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -240,7 +240,7 @@ function installControllers( ctx.provide('fileUploads', { registerAgentResolver: () => () => {}, resolve: () => undefined, - bindPrompt: () => () => {}, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) } diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index 17fe405c3a..736d5e6480 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -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/file-upload/README.md -README.md: be7f923fc7769bb337597a53a82b22be73866b8f -README.zh.md: fb355562c1249f85585cef4cbbaaa674a8ced690 +README.md: 5527bf6f544529088a255934a87af77be4865e12 +README.zh.md: 7825326863511cd7098635d563237bddeb46d035 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index be7f923fc7..5527bf6f54 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -44,7 +44,7 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. -The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. | File | Role | |---|---| diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index fb355562c1..7825326863 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -44,7 +44,7 @@ kind: "package-reference" Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 -Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。 +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 | 文件 | 职责 | |---|---| diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 4cd257f7e0..4d94d53b1b 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -33,6 +33,12 @@ interface StagedFileUpload { /** Resolve or resume the ordinary Agent that owns one Session identity. */ export type AgentResolver = (sessionId: SessionId) => Promise +/** Prompt receipt binding that restores its previous owners unless delivery commits it. */ +export interface PromptFileBinding extends Disposable { + /** Keep the receipt bindings until queue or history observation retires them. */ + commit(): void +} + type FileUploadHttpResult = | { readonly ok: true; readonly value: FileUploadValue } | { @@ -40,6 +46,22 @@ type FileUploadHttpResult = readonly error: { readonly code: string; readonly message: string; readonly details: object } } +class PromptFileBindingGuard implements PromptFileBinding { + private settled = false + + constructor(private readonly rollback: () => void) {} + + commit(): void { + this.settled = true + } + + [Symbol.dispose](): void { + if (this.settled) return + this.settled = true + this.rollback() + } +} + /** Host service owning upload storage and Agent-scoped staged receipts. */ export class FileUploads extends TypertRemoteService { static inject = ['agents', 'attachments', 'commands', 'connection'] @@ -129,13 +151,18 @@ export class FileUploads extends TypertRemoteService { } /** - * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. * @param agent - receiving Agent. * @param receiptIds - distinct staged receipts referenced by the prompt. * @param requestId - prompt identity later observed in queue or history. - * @returns rollback restoring every prior binding. + * @returns binding kept after commit until queue or history observation retires its receipts. */ - bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void { + bindPrompt( + agent: Agent, + receiptIds: readonly FileUploadReceiptId[], + requestId: string, + ): PromptFileBinding { this.assertAgentScope(agent) const staged = this.stagedFiles.get(agent.session) const bound = receiptIds.map((receiptId) => { @@ -144,12 +171,12 @@ export class FileUploads extends TypertRemoteService { return { upload, previous: upload.requestId } }) for (const { upload } of bound) upload.requestId = requestId - return () => { + return new PromptFileBindingGuard(() => { for (const { upload, previous } of bound) { if (previous === undefined) delete upload.requestId else upload.requestId = previous } - } + }) } /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 60e7b54d9e..d2f808d89d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -918,10 +918,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'durable file reference, or `undefined` for an unknown or foreign receipt.', }, { - signature: 'bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void', - description: 'Bind receipts to an accepted prompt and return a rollback for delivery failure.', + signature: 'bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding', + description: 'Bind receipts while one prompt enters an Agent inbox. Disposal restores every prior binding unless the caller commits successful delivery.', parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptIds', description: 'distinct staged receipts referenced by the prompt.' }, { name: 'requestId', description: 'prompt identity later observed in queue or history.' }], - returns: 'rollback restoring every prior binding.', + returns: 'binding kept after commit until queue or history observation retires its receipts.', }, { signature: 'retirePrompt(agent: Agent, requestId: string): void', @@ -4678,6 +4678,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptContextOrderName', declaration: 'export type PromptContextOrderName = keyof typeof CONTEXT_ORDERS;', }, + { + name: 'PromptFileBinding', + declaration: 'export interface PromptFileBinding extends Disposable {\n commit(): void;\n}', + }, { name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 371d6e62cd..ca4cc4aafa 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -346,6 +346,7 @@ export const LINK_MAP: Readonly> = { SessionUpdateQueueValue: 'session.md', EncodedFileUploadRequest: 'attachment.md', AgentResolver: 'attachment.md', + PromptFileBinding: 'attachment.md', FileUploadReceiptId: 'attachment.md', FileUploadValue: 'attachment.md', SessionStartSource: 'core.md', From ea669428bed0bcdac437bfc317674b9ff9ffa4c6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 20:53:29 +0800 Subject: [PATCH 26/37] refactor(attachment): unify prompt content admission --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 2 +- .../2026-08-26-generic-file-upload.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 20 +++++--- docs/subsystems/attachment.zh.md | 20 +++++--- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/commands.ts | 47 ++++++++----------- .../tests/commands-upload-file.host.spec.ts | 14 +++++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 25 ++++++---- packages/attachment/attachment/src/types.ts | 8 +++- .../attachment/tests/admission.spec.ts | 23 +++++++-- .../extensions/tool-cordis/src/api-catalog.ts | 12 +++-- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 20 files changed, 127 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 7511572021..5ba21dcd1a 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: b1beeb5afd50a1ff03ba583567202608260e8ebe -2026-08-26-generic-file-upload.zh.md: 0407cb76967c27831dfd8401da422d448290637c +2026-08-26-generic-file-upload.md: fcb60c2d02ec1aac5d9d40f05dad0eaa5b823cd9 +2026-08-26-generic-file-upload.zh.md: e696a8173c3f673d0bd574474816550fb8160158 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index b1beeb5afd..fcb60c2d02 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts an Agent Context together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it obtains the wire identity from Typert's registered `agent` Context adapter and assembles the raw route or scoped Remote request. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, byte storage, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. -Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. The Host validates every file receipt before persisting any image, rejects upload completion after the original Agent is disposed, and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 0407cb7696..e696a8173c 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -14,7 +14,7 @@ Status: implemented 文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收 Agent Context 与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它通过 Typert 已注册的 `agent` Context adapter 取得 wire identity,并组装原始路由或按 scope 寻址的 Remote 请求。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、字节存储、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 -默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Host 会在持久化任何图片前验证所有文件凭证,原 Agent 已销毁时拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 ## 考虑过的替代方案 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index f6bcc65c23..485ec384c8 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 6d9d4c86aa9435450a766c6aa74f4062824ba96b -attachment.zh.md: 4a8a7c3acf4203ed4d3cff2b92098086a5aa39a9 +attachment.md: 1ad7e27c4474d5a7544690a8a9e07e77c21597ac +attachment.zh.md: bd58de40b9a8bd675e5a555c5a16ff0b5d81d4bd diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 6d9d4c86aa..1ad7e27c44 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -80,10 +80,18 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } ``` ```ts type-equiv @@ -149,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` is the Host prompt entry and replaces base64 image uploads with durable references in part order. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -182,13 +190,13 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access attachment storage. - * @param content - browser prompt parts in message order. + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ -async admitPromptContent( content: readonly PromptContentPart[], ): Promise +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 4a8a7c3acf..bd58de40b9 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -80,10 +80,18 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } ``` ```ts type-equiv @@ -149,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 是 Host prompt 入口,按 part 顺序把 base64 图片上传替换为持久引用。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -182,13 +190,13 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access attachment storage. - * @param content - browser prompt parts in message order. + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ -async admitPromptContent( content: readonly PromptContentPart[], ): Promise +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 555f274fbc..db720e76c7 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: effcf3fe21b8091ee0eb012dd80dfe2f4e75d394 -README.zh.md: 6f1a260f60377c6a8079ffbde757fe4dbada9337 +README.md: a75dbdbaae526348e40da90ae7a57b702cb8b264 +README.zh.md: 5411878a5a87dd2b02cd46fc37f5aa6ddea291ac diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index effcf3fe21..a75dbdbaae 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and validates every same-Agent receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 6f1a260f60..5411878a5a 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,并在持久化图片前验证每个凭证属于同一个准确 Agent。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 8b2a4ad658..3671c4e31d 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -5,13 +5,15 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' -import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -328,12 +330,12 @@ export class SessionCommandController { ) } } - const durable = await durablePromptContent( - this.ctx, + const admission = resolvePromptFileReceipts( request.content, receiptId => this.ctx.fileUploads.resolve(agent, receiptId), ) - const message: UserMessage = createUserMessage({ content: durable.content, source }) + const content = await this.ctx.attachments.admitPromptContent(admission.content) + const message: UserMessage = createUserMessage({ content, source }) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -341,7 +343,7 @@ export class SessionCommandController { { sessionId: agent.id }, ) } - using binding = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) + using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId) if (request.mode === 'steer') agent.steer(message) else agent.followup(message) binding.commit() @@ -520,36 +522,25 @@ export class SessionCommandController { } } -async function durablePromptContent( - ctx: Context, - content: readonly SessionPromptRequest['content'][number][], +function resolvePromptFileReceipts( + content: SessionPromptRequest['content'], stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, -): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> { - const files = new Map() - for (const part of content) { - if (part.type !== 'file' || files.has(part.receiptId)) continue - const file = stagedFile(part.receiptId) - if (file === undefined) { +): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } { + const receiptIds = new Set() + const resolved = content.map((part): AttachmentAdmissionPart => { + if (part.type !== 'file') return part + const attachment = stagedFile(part.receiptId) + if (attachment === undefined) { throw new RemoteError( 'session/attachment-invalid', 'File was not uploaded for this session.', { reason: 'FILE_NOT_STAGED' }, ) } - files.set(part.receiptId, file) - } - type NonFilePart = Exclude - const admitted = await ctx.attachments.admitPromptContent( - content.filter((part): part is NonFilePart => part.type !== 'file'), - ) - let next = 0 - const durable = content.map((part) => { - if (part.type === 'file') { - return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef } - } - return admitted[next++] as ContentBlock + receiptIds.add(part.receiptId) + return { type: 'file', attachment } }) - return { content: durable, receiptIds: [...files.keys()] } + return { content: resolved, receiptIds: [...receiptIds] } } function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index acff794b8a..4e7565c7ff 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -126,8 +126,8 @@ describe('Session file uploads', () => { .resolves.toMatchObject({ status: 405 }) }) - it('stages one verbatim upload and cites it from a later prompt as a file block', async () => { - const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness() + it('stages one verbatim upload and preserves its order with an admitted image', async () => { + const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal) expect(saveFile).toHaveBeenCalledTimes(1) expect(receipt.file.name).toBe('notes.pdf') @@ -148,14 +148,24 @@ describe('Session file uploads', () => { expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({ attachments: [{ type: 'file', attachment: receipt.file }], }) + const image: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + } + saveImages.mockResolvedValueOnce([image]) await controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'text', text: 'read it' }, ])) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ { type: 'file', attachment: receipt.file }, + { type: 'image', attachment: image }, { type: 'text', text: 'read it' }, ]) }) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 2aceef5333..6064cc78ca 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: 1ab340bbd96881aceaa9ba20ace02fecb2f85052 -README.zh.md: 1fbc9163f202dac32f48d0ca39aa4ebe9559766f +README.md: 73c10f0a7c857291f7fbfe42042b9c9f4ce13da0 +README.zh.md: 43c95a8b072c6d9680af4a7f83c0e5c962aed48b diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 1ab340bbd9..73c10f0a7c 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers call `ctx.attachments.admitPromptContent()` to replace browser image uploads with durable references while retaining part order. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 1fbc9163f2..43c95a8b07 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方调用 `ctx.attachments.admitPromptContent()`,按原顺序把浏览器图片上传替换为持久引用。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1def0c6102..4fb08a85fc 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -5,11 +5,11 @@ import { admitEncodedImages } from './admission.ts' import { AttachmentError } from './error.ts' import type { AdmittedPromptContentPart, + AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, - PromptContentPart, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -25,6 +25,7 @@ export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, + AttachmentAdmissionPart, EncodedFileAttachment, EncodedImageAttachment, FileAttachmentRef, @@ -102,23 +103,27 @@ export abstract class AttachmentStore extends Service { } /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access attachment storage. - * @param content - browser prompt parts in message order. + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ async admitPromptContent( - content: readonly PromptContentPart[], + content: readonly AttachmentAdmissionPart[], ): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) + if (content.every(part => part.type !== 'image')) { + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'file', attachment: part.attachment }) } const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) + return content.map((part) => { + if (part.type === 'text') return { type: 'text', text: part.text } + if (part.type === 'file') return { type: 'file', attachment: part.attachment } + return { type: 'image', attachment: refs[next++] as ImageAttachmentRef } + }) } /** diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 032c7e07c8..a69b2363dc 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -106,10 +106,16 @@ export type PromptContentPart = readonly name?: string } -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +export type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + +/** Host-admitted prompt content with every attachment represented by its durable reference. */ export type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 7951164cd7..71c56c0fed 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,8 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import AttachmentStore, { admitEncodedFile, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveImageAttachment, +} from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes +const FILE_REF: FileAttachmentRef = { + attachmentId: 'file-1' as FileAttachmentRef['attachmentId'], + name: 'notes.md', + bytes: 12, +} /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { @@ -104,23 +111,29 @@ describe('admitEncodedFile', () => { }) describe('AttachmentStore.admitPromptContent', () => { - it('converts text-only prompts without touching the attachment store', async () => { + it('passes through text and durable files without touching image storage', async () => { const store = Object.setPrototypeOf({ - saveImages: () => { throw new Error('text-only prompts must not reach the store') }, + saveImages: () => { throw new Error('prompts without image uploads must not reach the store') }, }, AttachmentStore.prototype) as AttachmentStore await expect(store.admitPromptContent([ { type: 'text', text: 'hello' }, - ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) + { type: 'file', attachment: FILE_REF }, + ])).resolves.toEqual([ + { type: 'text', text: 'hello' }, + { type: 'file', attachment: FILE_REF }, + ]) }) - it('replaces image parts with admitted references in part order', async () => { + it('replaces images and passes through files in part order', async () => { const { store } = storeOf() await expect(store.admitPromptContent([ { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', mediaType: 'image/png', data: 'Ag==' }, ])).resolves.toEqual([ { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d2f808d89d..ba4b530452 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -483,9 +483,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'durable normalized attachment references in the same order after every member succeeds.', }, { - signature: 'async admitPromptContent( content: readonly PromptContentPart[], ): Promise', - description: 'Admit one browser prompt and replace each uploaded image with its durable reference. Text-only prompts do not access attachment storage.', - parameters: [{ name: 'content', description: 'browser prompt parts in message order.' }], + signature: 'async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise', + description: 'Admit one Host prompt and replace each uploaded image with its durable reference. Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation.', + parameters: [{ name: 'content', description: 'prompt parts in message order after file receipt resolution.' }], returns: 'admitted prompt parts in the same order as `content`.', throws: ['AttachmentError when the image batch is refused.'], }, @@ -3448,7 +3448,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AdmittedPromptContentPart', - declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n};', + declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', }, { name: 'Agent', @@ -3590,6 +3590,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantProvenance', declaration: 'export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n}', }, + { + name: 'AttachmentAdmissionPart', + declaration: 'export type AttachmentAdmissionPart = PromptContentPart | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', + }, { name: 'AttachmentId', declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ca4cc4aafa..6073ca5f4f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -359,6 +359,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', AdmittedPromptContentPart: 'attachment.md', + AttachmentAdmissionPart: 'attachment.md', EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', FileAttachmentRef: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c0109a078e..4cbd595653 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -951,6 +951,11 @@ "symbol": "PromptContentPart", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AttachmentAdmissionPart", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "AdmittedPromptContentPart", From 0516edb037ba2f8b8ceb5093bc4ff19d1025577a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 21:12:50 +0800 Subject: [PATCH 27/37] test(file-upload): restore host composition fixtures --- apps/cli/tests/web-agent-presets.e2e.ts | 7 +++++++ .../api/session-controller/tests/test-remote.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6c2ac3db7b..557cabe74a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -82,6 +82,9 @@ async function bootWeb( // skills test below proves it reaches preset-composed agents. { id: 'skill-badge', disabled: false }, { id: 'modules', disabled: true }, + // The physical Connection row owns the disabled HTTP server. bootWeb + // supplies only its in-process registries so Host services still prove + // their shipped dependency graph without binding a port. { id: 'connection', disabled: true }, // Export owns a Connection Fetch route, so this Host-only composition // disables it with the transport service above. @@ -139,6 +142,10 @@ async function bootWeb( const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { + bootCtx.provide('connection', { + fetch: { register: () => () => {} }, + rpc: { intercept: () => () => {} }, + } as never) provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ece2ad1512..f48527161f 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -3,6 +3,7 @@ import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import type { AdmittedPromptContentPart, AttachmentAdmissionPart } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceNotFoundError, @@ -236,6 +237,20 @@ function installControllers( }, } as never) } + if (ctx.get('attachments') === undefined) { + ctx.provide('attachments', { + admitPromptContent: async ( + content: readonly AttachmentAdmissionPart[], + ): Promise => { + const admitted: AdmittedPromptContentPart[] = [] + for (const part of content) { + if (part.type === 'image') throw new Error('test did not configure image persistence') + admitted.push(part) + } + return admitted + }, + } as never) + } if (ctx.get('fileUploads') === undefined) { ctx.provide('fileUploads', { registerAgentResolver: () => () => {}, From de022388eafe4ec209936ec6484cdaf28e955c40 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 21:25:45 +0800 Subject: [PATCH 28/37] test(ci): stabilize attachment fixtures --- apps/web/tests/agent-preset-authoring.e2e.ts | 21 ++++++------------- apps/web/tests/scaffold.ts | 18 ++++++++++++---- .../session-controller/tests/test-remote.ts | 16 +++++++++++++- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 72b8317fe4..4a3570e1ee 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -44,17 +44,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { return page.getByRole('dialog', { name: '设置' }) } - /** Tokenize the lane-owned preset root after general aria normalization. */ - function withPresetRoot(snapshot: string): string { - const rootSuffix = `/${userRoot.split('/').pop()!}` - return snapshot.split('\n').map((line) => { - const rootStart = line.indexOf(rootSuffix) - if (rootStart === -1) return line - const pathStart = line.lastIndexOf(' ', rootStart) + 1 - return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}` - }).join('\n') - } - beforeAll(async () => { userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) scaffold = await launchWebScaffold({ @@ -146,8 +135,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // The copy dialog is detached, so the settings dialog is the only one // left (it names itself via aria-labelledby, which a CSS attribute // selector cannot address). - const snapshot = withPresetRoot( - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { + replacements: [[userRoot, '{{presetRoot}}']], + }) await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) expect(snapshot).toContain('{{presetRoot}}/my-agent') @@ -196,8 +186,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.getByRole('button', { name: 'Agent 预设' }).click() await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 }) - const snapshot = withPresetRoot( - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { + replacements: [[userRoot, '{{presetRoot}}']], + }) await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) // Both damage shapes surface as marked, unselectable, uncopyable cards // that still carry their metadata and the discovery-reported reason. diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index d53e18f5ee..46bd878dea 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1177,20 +1177,30 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st * @param selector - the region locator selector. * @param workspaceCwd - normalization input. * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}` - * for a region whose rows are dated from live wall-clock state. + * for a region whose rows are dated from live wall-clock state; + * `replacements` tokenizes scenario-owned values before generic normalization. * @returns the stable normalized snapshot. */ export async function captureStableAria( page: Page, selector: string, workspaceCwd: string, - options: { normalizeAge?: boolean } = {}, + options: { + normalizeAge?: boolean + replacements?: readonly (readonly [value: string, token: string])[] + } = {}, ): Promise { const region = page.locator(selector).first() const age = options.normalizeAge === true - let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) + const normalize = (snapshot: string): string => { + for (const [value, token] of options.replacements ?? []) { + snapshot = snapshot.split(value).join(token) + } + return normalizeAria(snapshot, workspaceCwd, age) + } + let previous = normalize(await region.ariaSnapshot()) await expect.poll(async () => { - const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) + const current = normalize(await region.ariaSnapshot()) const stable = current === previous previous = current return stable diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index f48527161f..9bc52b3a5c 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -3,7 +3,11 @@ import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import type { AdmittedPromptContentPart, AttachmentAdmissionPart } from '@deepseek-ai/dsh-attachment' +import type { + AdmittedPromptContentPart, + AttachmentAdmissionPart, + ImageAttachmentLimits, +} from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceNotFoundError, @@ -94,6 +98,15 @@ export interface TestSessionRemoteDefaults { const installed = new WeakMap() +const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), +}) + /** Compact header-and-events point read a persistence double declares per session. */ interface TestSessionInspection { readonly meta: SessionHeader @@ -239,6 +252,7 @@ function installControllers( } if (ctx.get('attachments') === undefined) { ctx.provide('attachments', { + imageLimits: TEST_IMAGE_LIMITS, admitPromptContent: async ( content: readonly AttachmentAdmissionPart[], ): Promise => { From 48656b4594a935fe3ae534fda81d8d5c0c845f97 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 4 Sep 2026 10:14:15 +0800 Subject: [PATCH 29/37] test(session-controller): restore Session type import --- packages/api/session-controller/tests/session-cold.host.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 8304677517..deb58794ca 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -15,7 +15,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, From 705cd220d44d3ded43487a2528c2feb3e9f7e9bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 4 Sep 2026 10:29:41 +0800 Subject: [PATCH 30/37] test(web): migrate file upload snapshot to v2 --- apps/web/tests/file-upload-round.e2e.ts | 6 +++--- .../{session.jsonl => session.v2.jsonl} | 17 ++++------------- 2 files changed, 7 insertions(+), 16 deletions(-) rename snapshots/web/file-upload-round/{session.jsonl => session.v2.jsonl} (75%) diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index 380c0e97f2..409c46ca6c 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -6,7 +6,7 @@ // content-addressed store makes the saved path identical across record and // replay once the workspace cwd is tokenized, so the recorded read arguments // replay verbatim against a freshly re-uploaded object. -// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless +// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -21,7 +21,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) @@ -277,7 +277,7 @@ describe('web e2e: generic file upload through the real assembly', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + 'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', ]) }) }) diff --git a/snapshots/web/file-upload-round/session.jsonl b/snapshots/web/file-upload-round/session.v2.jsonl similarity index 75% rename from snapshots/web/file-upload-round/session.jsonl rename to snapshots/web/file-upload-round/session.v2.jsonl index 7a481908b7..0a2dc6ed92 100644 --- a/snapshots/web/file-upload-round/session.jsonl +++ b/snapshots/web/file-upload-round/session.v2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787887026413,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":2,"id":"{{session:1}}","createdAt":1787887026413,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,20 +12,11 @@ {"type":"session/title","data":{"title":"Read the attached file with","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_x83IHPfS8ouwILXe8u729956"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_x83IHPfS8ouwILXe8u729956","content":[{"type":"text","text":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\nfile\n\n1: UPLOAD_ROUND_OK\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt","offset":1,"lines":[{"number":1,"text":"UPLOAD_ROUND_OK"}],"totalLines":1}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_x83IHPfS8ouwILXe8u729956"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_x83IHPfS8ouwILXe8u729956","content":[{"type":"text","text":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\nfile\n\n1: UPLOAD_ROUND_OK\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt","offset":1,"lines":[{"number":1,"text":"UPLOAD_ROUND_OK"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,15,17,15,17],"texts":["U","PL","O","AD","_RO","UND","_OK"]}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UPLOAD_ROUND_OK"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UPLOAD_ROUND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}},"sourceEventSeqs":[22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UPLOAD_ROUND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[17,17,15,17,15,17],"texts":["U","PL","O","AD","_RO","UND","_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UPLOAD_ROUND_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From f0e52c2731a9074e6217c800b1f05d3bd92e4eac Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 4 Sep 2026 13:08:25 +0800 Subject: [PATCH 31/37] refactor(file-upload): tighten service boundaries --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 5 +- .../2026-08-26-generic-file-upload.zh.md | 5 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 17 +++++- docs/subsystems/attachment.zh.md | 17 +++++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 24 +++++++- .../attachment/attachment/tests/index.spec.ts | 28 +++++++++ packages/client/file-upload/README.i18n.yaml | 4 +- packages/client/file-upload/README.md | 17 +++--- packages/client/file-upload/README.zh.md | 17 +++--- packages/client/file-upload/package.json | 8 +-- .../client/file-upload/src/client/contract.ts | 16 ++--- .../client/file-upload/src/client/index.ts | 9 +-- .../client/file-upload/src/client/runtime.ts | 42 ++++++------- packages/client/file-upload/src/index.ts | 11 ++-- .../tests/file-upload.client.spec.ts | 60 +++++++------------ .../client/file-upload/tsconfig.client.json | 1 + .../ui-conversation/src/client/service.ts | 4 +- .../tests/apply-inject.client.spec.tsx | 8 +-- .../service-orchestration.client.spec.ts | 9 +-- .../extensions/tool-cordis/src/api-catalog.ts | 25 ++++++++ .../test-support/client-runtime/src/index.ts | 3 +- .../tests/helpers.client.spec.tsx | 2 +- pnpm-lock.yaml | 14 ++--- scripts/gen-cordis-catalog.ts | 1 + scripts/package-dependency-policy.ts | 2 - 30 files changed, 223 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 5ba21dcd1a..fa6f76a300 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: fcb60c2d02ec1aac5d9d40f05dad0eaa5b823cd9 -2026-08-26-generic-file-upload.zh.md: e696a8173c3f673d0bd574474816550fb8160158 +2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402 +2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index fcb60c2d02..d8643f5f1a 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -12,7 +12,7 @@ The command plane had already established a whole-envelope rule for images: a co ## Decision -Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts an Agent Context together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it obtains the wire identity from Typert's registered `agent` Context adapter and assembles the raw route or scoped Remote request. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, byte storage, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. +Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. @@ -28,7 +28,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- - **Store objective attachments in the goal domain.** Rejected: one ordinary logged user message gives later goal rounds the same history without widening the goal schema or duplicating attachments into round prompts. - **Consume attachments on any command success.** Rejected: `/goal pause` or `/plan off` would clear unrelated cards. Consumption follows the producer's explicit success; grammar forms that cannot use attachments return an error. - **Enforce declarations only in the client.** Rejected: direct RPC callers can bypass the composer. `CommandRuntime` enforces declaration, store availability, same-Session file-receipt resolution, image base64 admission, and image limits before entering the handler. -- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. The independent upload service accepts the existing Agent Context directly and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session. +- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. The independent upload service accepts an explicit Session identity and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session. ## Consequences @@ -37,6 +37,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- - Staged uploads live in Host memory per Session; a Host restart between upload and send surfaces `FILE_NOT_STAGED` and the client re-adds the file. - The browser-to-Host transfer is one streaming HTTP request without resumable offsets; retrying after connection or Host failure retransmits the file from byte zero. - A `ReadableStream` upload consumes and transfers the stream once; retrying requires a newly created stream, and progress cannot report a total unless the producer supplies length through a future API. +- The browser upload Worker is emitted from the string form of a self-contained function. Runtime imports require a standalone Worker entry bundled by tsdown. - Commands must declare attachment acceptance. Rejected commands preserve the text draft and every attachment card for retry. - Subagents do not upload files independently. A main agent delegates with the saved path; only same-machine or inherited-history children whose execution environment maps that path can read it. - Uploaded files do not appear in `@` completion yet; that integration is a planned later phase. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index e696a8173c..38de46a424 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收 Agent Context 与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它通过 Typert 已注册的 `agent` Context adapter 取得 wire identity,并组装原始路由或按 scope 寻址的 Remote 请求。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、字节存储、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 +文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 @@ -28,7 +28,7 @@ Status: implemented - **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。 - **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。 - **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。 -- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收现有 Agent Context,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。 +- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收 Session 标识,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。 ## 后果 @@ -37,6 +37,7 @@ Status: implemented - 暂存上传按 Session 存在 Host 内存里;上传与发送之间 Host 重启会出现 `FILE_NOT_STAGED`,客户端重新添加文件即可。 - 浏览器到 Host 的传输使用一次不带断点续传偏移的流式 HTTP 请求;连接或 Host 失败后的重试会从第一个字节重新传输整个文件。 - `ReadableStream` 上传只消费并转移一次 stream;重试必须重新创建 stream,而且在未来 API 允许生产方提供长度前,进度不能报告总量。 +- 浏览器上传 Worker 由自包含函数的字符串形式生成。需要运行时 import 时,必须改用由 tsdown 打包的独立 Worker 入口。 - 命令必须声明接受附件。命令拒绝时保留文本草稿与全部附件卡,供用户重试。 - 子代理不独立上传文件。主 agent 委派时传递保存路径;只有同机或继承父级历史且执行环境能映射该路径的子级可以读取。 - 上传的文件尚未进入 `@` 补全;该联动是计划中的后续阶段。 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 485ec384c8..3e121e78c9 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 1ad7e27c4474d5a7544690a8a9e07e77c21597ac -attachment.zh.md: bd58de40b9a8bd675e5a555c5a16ff0b5d81d4bd +attachment.md: 53b3e51e5b1c87625256178287061386342bc055 +attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 1ad7e27c44..53b3e51e5b 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -157,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -198,6 +198,21 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Decode and durably commit one canonical base64 file upload. + * @param input - canonical base64 bytes and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError when the encoding or storage operation is refused. + */ +admitEncodedFile(input: EncodedFileAttachment): Promise + +/** + * Identify a failure emitted by this attachment capability by its stable code. + * @param error - value caught from an attachment operation. + * @returns whether the value is an attachment failure. + */ +isAttachmentError(error: unknown): error is AttachmentError + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index bd58de40b9..549c06cc5a 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -157,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -198,6 +198,21 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Decode and durably commit one canonical base64 file upload. + * @param input - canonical base64 bytes and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError when the encoding or storage operation is refused. + */ +admitEncodedFile(input: EncodedFileAttachment): Promise + +/** + * Identify a failure emitted by this attachment capability by its stable code. + * @param error - value caught from an attachment operation. + * @returns whether the value is an attachment failure. + */ +isAttachmentError(error: unknown): error is AttachmentError + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 6064cc78ca..d92fc02572 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: 73c10f0a7c857291f7fbfe42042b9c9f4ce13da0 -README.zh.md: 43c95a8b072c6d9680af4a7f83c0e5c962aed48b +README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936 +README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 73c10f0a7c..a812182aef 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 43c95a8b07..c487f8204c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 4fb08a85fc..5df42a8bda 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,11 +1,12 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ import { Context, Service } from '@deepseek-ai/cordis' -import { admitEncodedImages } from './admission.ts' -import { AttachmentError } from './error.ts' +import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts' +import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts' import type { AdmittedPromptContentPart, AttachmentAdmissionPart, + EncodedFileAttachment, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, @@ -126,6 +127,25 @@ export abstract class AttachmentStore extends Service { }) } + /** + * Decode and durably commit one canonical base64 file upload. + * @param input - canonical base64 bytes and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError when the encoding or storage operation is refused. + */ + admitEncodedFile(input: EncodedFileAttachment): Promise { + return admitFileInput(this, input) + } + + /** + * Identify a failure emitted by this attachment capability by its stable code. + * @param error - value caught from an attachment operation. + * @returns whether the value is an attachment failure. + */ + isAttachmentError(error: unknown): error is AttachmentError { + return matchesAttachmentError(error) + } + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index d645d35e70..923c1a4360 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -10,6 +10,7 @@ import AttachmentStore, { type ImageMediaType, type ImageRequestPolicy, type RequestImageAttachment, + type SaveFileAttachment, type SaveImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -89,6 +90,19 @@ class UnsupportedProjectionStore extends AttachmentStore { } } +class RecordingFileStore extends RecordingStore { + fileInput: SaveFileAttachment | undefined + + override saveFile(input: SaveFileAttachment) { + this.fileInput = input + return Promise.resolve({ + attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), + name: input.name ?? 'unnamed', + bytes: input.data.byteLength, + }) + } +} + function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment { return { data: Uint8Array.of(value), mediaType, name: `${value}.png` } } @@ -178,6 +192,20 @@ describe('AttachmentStore.readImageRequest', () => { }) }) +describe('AttachmentStore file admission', () => { + it('decodes encoded files through the service and exposes attachment errors', async () => { + const store = new RecordingFileStore(new Context()) + + await expect(store.admitEncodedFile({ data: 'AQID', name: 'notes.bin' })).resolves.toMatchObject({ + name: 'notes.bin', + bytes: 3, + }) + expect(store.fileInput).toEqual({ data: Uint8Array.of(1, 2, 3), name: 'notes.bin' }) + expect(store.isAttachmentError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(true) + expect(store.isAttachmentError(new Error('unknown failure'))).toBe(false) + }) +}) + describe('isImageAdmissionError', () => { it('separates caller-correctable image admission failures from storage faults', () => { expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true) diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index 736d5e6480..e6b4cb5fcb 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -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/file-upload/README.md -README.md: 5527bf6f544529088a255934a87af77be4865e12 -README.zh.md: 7825326863511cd7098635d563237bddeb46d035 +README.md: 2c19e1a564d7b58226e6ccd3cb5b7c81e1b1c14e +README.zh.md: d1553d40d953dd6378a954c43a9b7a36b7334953 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index 5527bf6f54..2c19e1a564 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -1,5 +1,5 @@ --- -description: "Agent-scoped browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts." +description: "Session-addressed browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` under one Agent scope and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs. +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs. ## Table of Contents @@ -25,14 +25,14 @@ This package lets browser features store a `Blob`, exact bytes, or a `ReadableSt ## Use this package -Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`. The service derives the wire identity from `agentContext`; callers do not assemble an upload URL or Remote request. +Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request. ```yaml - id: file-upload name: '@deepseek-ai/dsh-client-file-upload' ``` -The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the scoped generated Remote. +The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the generated Remote. ----- @@ -42,13 +42,13 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins
Implementation internals — click to expand -The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. +The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. -The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Host streaming route, storage, and Agent-scoped receipt lifecycle | +| [`src/index.ts`](src/index.ts) | Host streaming route, attachment-service admission, and Agent-scoped receipt lifecycle | | [`src/types.ts`](src/types.ts) | encoded request, receipt, and durable result types | | [`src/client/contract.ts`](src/client/contract.ts) | Client upload, progress, and page-hook types | | [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations | @@ -56,7 +56,7 @@ The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming
-**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Agent scope, and each request uses one selected carrier. Unscoped calls and unsupported stream carriers fail before the body is sent. +**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Session, and each request uses one selected carrier. Unsupported stream carriers fail before the body is sent. ----- @@ -88,6 +88,7 @@ These limits apply to the transport operation itself. - **Uploads are not resumable** — a failed or cancelled retry starts from the first byte. - **Stream bodies are one-shot** — transferring a `ReadableStream` locks the caller's object, so retry requires a newly created stream. - **Stream progress has no total** — callers receive consumed-byte counts because the stream API carries no byte length. +- **The browser Worker is self-contained** — its source is emitted from a function string. Adding runtime imports requires moving it to a standalone Worker entry bundled by tsdown. ### Dev Note diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index 7825326863..d1553d40d9 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -1,5 +1,5 @@ --- -description: "按 Agent scope 上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。" +description: "按 Session 寻址上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让浏览器功能在一个 Agent scope 下存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。 +本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。 ## 目录 @@ -25,14 +25,14 @@ kind: "package-reference" ## 使用本包 -在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`。服务从 `agentContext` 得到 wire identity;调用方不组装上传 URL 或 Remote 请求。 +在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。 ```yaml - id: file-upload name: '@deepseek-ai/dsh-client-file-upload' ``` -本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用按 scope 寻址的生成 Remote。 +本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用生成的 Remote。 ----- @@ -42,13 +42,13 @@ kind: "package-reference"
实现细节——点击展开 -Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 +Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为可重放输入调用生成的 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 -Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | Host 流式路由、存储与按 Agent scope 管理的凭证生命周期 | +| [`src/index.ts`](src/index.ts) | Host 流式路由、附件服务准入与按 Agent scope 管理的凭证生命周期 | | [`src/types.ts`](src/types.ts) | 编码请求、凭证与持久结果类型 | | [`src/client/contract.ts`](src/client/contract.ts) | Client 上传、进度与页面钩子类型 | | [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 | @@ -56,7 +56,7 @@ Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编
-**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Agent scope,每个请求只使用一个已选定载体。没有 scope 的调用和载体不支持的 stream 会在发送请求体前失败。 +**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Session,每个请求只使用一个已选定载体。载体不支持的 stream 会在发送请求体前失败。 ----- @@ -88,6 +88,7 @@ Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编 - **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。 - **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。 - **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。 +- **浏览器 Worker 必须自包含**:其源代码由函数字符串生成。如果实现需要运行时 import,就必须迁移为由 tsdown 打包的独立 Worker 入口。 ### 开发备注 diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 62d760912e..64390dc56e 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -64,8 +64,7 @@ "dependencies": { "zod": "^4.4.3", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -74,11 +73,12 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^" + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" } } diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 407384f69a..5f509ace06 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -1,4 +1,6 @@ -import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { FileUploadValue } from '../types.ts' /** Browser request body accepted by the background file-upload service. */ export type FileUploadBody = Blob | ReadableStream @@ -9,14 +11,14 @@ export interface FileUploadProgress { readonly total?: number } -/** Browser upload service addressed through one Client Agent scope. */ +/** Browser upload service addressed by one Session identity. */ export interface FileUploadService { /** Whether this page has a Host-backed background upload carrier. */ readonly available: boolean /** - * Store one file under an Agent scope. Blob and stream bodies use + * Store one file for a Session. Blob and stream bodies use * the background carrier; exact bytes and fixture fallbacks use Remote. - * @param owner - Agent-scoped Client context that owns the staged receipt. + * @param sessionId - Session that owns the staged receipt. * @param data - browser Blob, exact bytes, or a one-shot byte stream. * @param name - optional display name. * @param signal - optional cancellation for the active upload. @@ -24,14 +26,12 @@ export interface FileUploadService { * @returns the staged receipt and durable file reference, or a business error. */ upload( - owner: Context, + sessionId: SessionId, data: Blob | Uint8Array | ReadableStream, name?: string, signal?: AbortSignal, onProgress?: (progress: FileUploadProgress) => void, - ): Promise> + ): Promise> } export type { ClientFileUploadHooks, FileUploadFetch } from '../types.ts' diff --git a/packages/client/file-upload/src/client/index.ts b/packages/client/file-upload/src/client/index.ts index 39486ad404..a9b6bb3ef8 100644 --- a/packages/client/file-upload/src/client/index.ts +++ b/packages/client/file-upload/src/client/index.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { FileUploadRuntime } from './runtime.ts' +import type { FileUploadService } from './contract.ts' export type { ClientFileUploadHooks, @@ -14,13 +15,13 @@ export type { FileUploadReceiptId, FileUploadValue } from '../types.ts' declare module '@deepseek-ai/cordis' { interface Context { - /** Agent-scoped browser service for staged file uploads. */ - fileUpload: import('./contract.ts').FileUploadService + /** Session-addressed browser service for staged file uploads. */ + fileUpload: FileUploadService } } -/** The upload service resolves Agent identity and the generated Remote fallback. */ -export const inject = ['typert', 'remote'] +/** The upload service uses the generated Remote fallback. */ +export const inject = ['remote'] /** * Provide the browser background-upload service. diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index cc855391cd..38ab535a1f 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -3,9 +3,10 @@ import { Service, type Context } from '@deepseek-ai/cordis' import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteResult, TypertContextRegistry } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { FILE_UPLOAD_PATH } from '../protocol.ts' -import type { FileUploadValue } from '../types.ts' +import type { EncodedFileUploadRequest, FileUploadValue } from '../types.ts' import type { ClientFileUploadHooks, FileUploadBody, @@ -26,21 +27,18 @@ interface FileUploadResponse { readonly body: string } -interface AgentFileUploadContext extends Context { +interface FileUploadRemoteContext extends Context { readonly remote: { readonly fileUploads: { upload( - request: import('../types.ts').EncodedFileUploadRequest, + sessionId: SessionId, + request: EncodedFileUploadRequest, signal?: AbortSignal, ): Promise> } } } -interface FileUploadTypert { - readonly contexts: Pick -} - interface UploadWorkerStart { readonly url: string readonly body: FileUploadBody @@ -187,8 +185,8 @@ export class FileUploadRuntime extends Service implements FileUploadService { } /** - * Store one file under an Agent scope. - * @param owner - Agent-scoped Client context that owns the staged receipt. + * Store one file for a Session. + * @param sessionId - Session that owns the staged receipt. * @param data - browser Blob, exact bytes, or a one-shot byte stream. * @param name - optional display name. * @param signal - optional cancellation for the active upload. @@ -196,13 +194,12 @@ export class FileUploadRuntime extends Service implements FileUploadService { * @returns the staged receipt and durable file reference, or a business error. */ async upload( - owner: Context, + sessionId: SessionId, data: Blob | Uint8Array | ReadableStream, name?: string, signal?: AbortSignal, onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, ): Promise> { - const sessionId = this.sessionId(owner) if (!(data instanceof Uint8Array) && this.available) { const query = new URLSearchParams({ sessionId }) if (name !== undefined) query.set('name', name) @@ -222,19 +219,14 @@ export class FileUploadRuntime extends Service implements FileUploadService { throw new Error('stream file upload requires a background carrier') } const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) - return (owner as AgentFileUploadContext).remote.fileUploads.upload({ - data: bytesToBase64(bytes), - ...(name === undefined ? {} : { name }), - }, signal) - } - - private sessionId(owner: Context): string { - const typert = this.ctx.get('typert') as FileUploadTypert | undefined - const sessionId = typert?.contexts.getClient('agent')?.identity(owner) - if (typeof sessionId !== 'string' || sessionId === '') { - throw new Error('fileUpload.upload requires an Agent-scoped context') - } - return sessionId + return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( + sessionId, + { + data: bytesToBase64(bytes), + ...(name === undefined ? {} : { name }), + }, + signal, + ) } } diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 4d94d53b1b..531ec3e464 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -3,13 +3,12 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { admitEncodedFile, isAttachmentError } from '@deepseek-ai/dsh-attachment' import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-client-connection' import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' import { scopeOf } from '@deepseek-ai/dsh-scope' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { FILE_UPLOAD_PATH } from './protocol.ts' import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' @@ -114,7 +113,7 @@ export class FileUploads extends TypertRemoteService { @Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - return this.commit(agent, async () => admitEncodedFile(this.ctx.attachments, { + return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({ data: request.data, ...(request.name === undefined ? {} : { name: request.name }), })) @@ -195,7 +194,7 @@ export class FileUploads extends TypertRemoteService { try { file = await save() } catch (error) { - if (isAttachmentError(error)) { + if (this.ctx.attachments.isAttachmentError(error)) { throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never) } throw new RemoteError( @@ -288,7 +287,7 @@ export async function handleFileUploadHttp(service: FileUploads, request: Reques result = { ok: true, value: await service.uploadStream({ - sessionId: SessionId(sessionId), + sessionId: brandString(sessionId), data: requestBodyChunks(request.body), signal: request.signal, ...(name === undefined ? {} : { name }), diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 14a26c8dae..510993d94f 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -1,4 +1,5 @@ import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply } from '../src/client/index.ts' import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' @@ -378,22 +379,13 @@ describe('file upload service', () => { }) }) -describe('Agent-scoped file upload', () => { +describe('Session-addressed file upload', () => { + const SESSION_ID = 's1' as SessionId + async function scopedService(options: { - readonly sessionId?: string readonly remote?: ReturnType } = {}) { const ctx = new Context() - ctx.provide('sessions', { - scopeOf: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined, - } as never) - ctx.provide('typert', { - contexts: { - getClient: (kind: string) => kind === 'agent' - ? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined } - : undefined, - }, - } as never) const remote = options.remote ?? vi.fn(() => Promise.resolve({ ok: true, value: { @@ -404,10 +396,7 @@ describe('Agent-scoped file upload', () => { ctx.provide('remote', { fileUploads: { upload: remote } } as never) const fiber = ctx.plugin(FileUploadRuntime) await fiber - const owner = options.sessionId === undefined - ? ctx - : ctx.extend({ fixtureSessionId: options.sessionId }) - return { ctx, fiber, owner, remote, service: ctx.fileUpload } + return { ctx, fiber, remote, service: ctx.fileUpload } } it('assembles the scoped streaming request and parses progress and receipt fields', async () => { @@ -425,11 +414,11 @@ describe('Agent-scoped file upload', () => { }), { status: 200 })) }) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } - const { fiber, owner, service } = await scopedService({ sessionId: 's1' }) + const { fiber, service } = await scopedService() const signal = new AbortController().signal const file = new Blob(['data']) - await expect(service.upload(owner, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({ + await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({ ok: true, value: { receiptId: 'receipt-1', @@ -448,7 +437,7 @@ describe('Agent-scoped file upload', () => { await fiber.dispose() }) - it('uses the scoped Remote fallback for exact bytes and fixture Blob bodies', async () => { + it('uses the direct Remote fallback for exact bytes and fixture Blob bodies', async () => { vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) const remote = vi.fn(() => Promise.resolve({ ok: true, @@ -457,28 +446,23 @@ describe('Agent-scoped file upload', () => { file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 }, }, })) - const { fiber, owner, service } = await scopedService({ sessionId: 's1', remote }) - await expect(service.upload(owner, Uint8Array.of(0, 0, 0), 'bytes.bin')) + const { fiber, service } = await scopedService({ remote }) + await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin')) .resolves.toMatchObject({ ok: true }) - await expect(service.upload(owner, new Blob([Uint8Array.of(1)]))) + await expect(service.upload(SESSION_ID, new Blob([Uint8Array.of(1)]))) .resolves.toMatchObject({ ok: true }) expect(remote.mock.calls).toEqual([ - [{ data: 'AAAA', name: 'bytes.bin' }, undefined], - [{ data: 'AQ==' }, undefined], + [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined], + [SESSION_ID, { data: 'AQ==' }, undefined], ]) await fiber.dispose() }) - it('rejects an unscoped call, an unavailable stream, and malformed background results', async () => { + it('rejects an unavailable stream and malformed background results', async () => { vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) - const unscoped = await scopedService() - await expect(unscoped.service.upload(unscoped.owner, Uint8Array.of(1))) - .rejects.toThrow('fileUpload.upload requires an Agent-scoped context') - await unscoped.fiber.dispose() - - const fixture = await scopedService({ sessionId: 's1' }) + const fixture = await scopedService() const stream = new ReadableStream({ start(controller) { controller.close() } }) - await expect(fixture.service.upload(fixture.owner, stream)) + await expect(fixture.service.upload(SESSION_ID, stream)) .rejects.toThrow('stream file upload requires a background carrier') await fixture.fiber.dispose() @@ -486,8 +470,8 @@ describe('Agent-scoped file upload', () => { ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch: () => Promise.resolve(new Response(null, { status: 413 })), } - const rejected = await scopedService({ sessionId: 's1' }) - await expect(rejected.service.upload(rejected.owner, new Blob())) + const rejected = await scopedService() + await expect(rejected.service.upload(SESSION_ID, new Blob())) .rejects.toThrow('file upload transport failed with HTTP 413') await rejected.fiber.dispose() @@ -505,8 +489,8 @@ describe('Agent-scoped file upload', () => { ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), } - const malformed = await scopedService({ sessionId: 's1' }) - await expect(malformed.service.upload(malformed.owner, new Blob())) + const malformed = await scopedService() + await expect(malformed.service.upload(SESSION_ID, new Blob())) .rejects.toThrow(/file upload transport returned an invalid/) await malformed.fiber.dispose() } @@ -517,8 +501,8 @@ describe('Agent-scoped file upload', () => { error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, }), { status: 200 })), } - const failed = await scopedService({ sessionId: 's1' }) - await expect(failed.service.upload(failed.owner, new Blob())).resolves.toMatchObject({ + const failed = await scopedService() + await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({ ok: false, error: { code: 'session/attachment-invalid', diff --git a/packages/client/file-upload/tsconfig.client.json b/packages/client/file-upload/tsconfig.client.json index 75f6822c60..dc25e3845d 100644 --- a/packages/client/file-upload/tsconfig.client.json +++ b/packages/client/file-upload/tsconfig.client.json @@ -17,6 +17,7 @@ { "path": "../../util/brand" }, { "path": "../../util/crypto" }, { "path": "../../attachment/attachment" }, + { "path": "../../core/session" }, { "path": "../../typert/protocol" } ] } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0cb98f55cd..29023e1b92 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -363,10 +363,8 @@ export class ConversationController extends Service implements IConversation { try { if (controller.signal.aborted || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return - const owner = this.requireSessions().scope(sessionId) - if (owner === undefined) throw new Error(`conversation: session "${sessionId}" resolved no Agent scope`) const result = await this.ctx.fileUpload.upload( - owner, + sessionId, attachment.file, attachment.file.name === '' ? undefined : attachment.file.name, controller.signal, diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 846cb5067d..2dde9ce7b3 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import type { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -43,10 +42,9 @@ async function bench() { })) const uploads = new Map Promise>([[ROOT, rootUpload]]) runtime.fileUpload.available = true - runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => { - const id = runtime.sessions.scopeOf(owner) - const upload = id === undefined ? undefined : uploads.get(id) - if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture') + runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { + const upload = uploads.get(sessionId) + if (upload === undefined) throw new Error('test file upload has no Session fixture') return upload(...args) } runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index a78feb2e51..ff46ef3680 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -9,6 +9,7 @@ import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-cl import type { BeginSubmissionInput, PendingSubmissionRetirement, QueuedMessage, } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' import { ConversationController } from '../src/client/service.ts' @@ -17,11 +18,11 @@ import { zh } from '../src/client/locales.ts' async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() runtime.fileUpload.available = true - runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => { - const session = runtime.sessions.sessionOf(owner) as { + runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { + const session = runtime.sessions.behavior(sessionId) as { uploadFile?: (...input: unknown[]) => Promise - } | undefined - if (session?.uploadFile === undefined) throw new Error('test file upload has no Session override') + } + if (session.uploadFile === undefined) throw new Error('test file upload has no Session override') return session.uploadFile(...args) } const prompt = vi.fn(( diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5f34f77968..4b37302619 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -489,6 +489,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'admitted prompt parts in the same order as `content`.', throws: ['AttachmentError when the image batch is refused.'], }, + { + signature: 'admitEncodedFile(input: EncodedFileAttachment): Promise', + description: 'Decode and durably commit one canonical base64 file upload.', + parameters: [{ name: 'input', description: 'canonical base64 bytes and optional display name.' }], + returns: 'the durable content-addressed file reference.', + throws: ['AttachmentError when the encoding or storage operation is refused.'], + }, + { + signature: 'isAttachmentError(error: unknown): error is AttachmentError', + description: 'Identify a failure emitted by this attachment capability by its stable code.', + parameters: [{ name: 'error', description: 'value caught from an attachment operation.' }], + returns: 'whether the value is an attachment failure.', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.', @@ -3616,6 +3629,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AttachmentAdmissionPart', declaration: 'export type AttachmentAdmissionPart = PromptContentPart | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', }, + { + name: 'AttachmentError', + declaration: 'export class AttachmentError extends Error {\n readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}', + }, + { + name: 'AttachmentErrorCode', + declaration: 'export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number];', + }, { name: 'AttachmentId', declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', @@ -4064,6 +4085,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'EncodedFileAttachment', + declaration: 'export interface EncodedFileAttachment {\n data: string;\n name?: string;\n}', + }, { name: 'EncodedFileUploadRequest', declaration: 'export interface EncodedFileUploadRequest {\n readonly data: string;\n readonly name?: string;\n}', diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 49e5b0056e..7d0d37c9cb 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -28,6 +28,7 @@ import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui import { apply as applyUiSession, inject as uiSessionInject, } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer, SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike, @@ -115,7 +116,7 @@ export interface TestFileUpload { /** Availability reported to the feature under test. */ available: boolean /** Test-supplied upload behavior; the default rejects every call. */ - upload: (owner: Context, ...args: unknown[]) => Promise + upload: (sessionId: SessionId, ...args: unknown[]) => Promise } /** diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index f4ee898406..c3904623ba 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -41,7 +41,7 @@ describe('fixture helpers', () => { it('rejects an upload until a suite replaces the default stub', async () => { const runtime = await SlotTestRuntime.create() expect(runtime.fileUpload.available).toBe(false) - await expect(runtime.fileUpload.upload(runtime.ctx)).rejects.toThrow('file upload is not stubbed') + await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed') await runtime.dispose() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce1f213a30..4bac153c00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1746,12 +1746,9 @@ importers: packages/client/file-upload: dependencies: - '@deepseek-ai/dsh-attachment': + '@deepseek-ai/dsh-brand': specifier: workspace:^ - version: link:../../attachment/attachment - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session + version: link:../../util/brand '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -1768,9 +1765,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-brand': + '@deepseek-ai/dsh-attachment': specifier: workspace:^ - version: link:../../util/brand + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1780,6 +1777,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9c22a768e9..a11e187bfb 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -361,6 +361,7 @@ export const LINK_MAP: Readonly> = { AskUserQuestionRequestEvent: 'user-questions.md', AdmittedPromptContentPart: 'attachment.md', AttachmentAdmissionPart: 'attachment.md', + AttachmentError: 'attachment.md', EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', FileAttachmentRef: 'attachment.md', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index e4f658c128..e20b293a95 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -41,11 +41,9 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ * Runtime exports whose values remain valid when npm installs another package copy. */ const SAFE_HOST_DEPENDENCY_EXPORTS = { - '@deepseek-ai/dsh-attachment': ['admitEncodedFile', 'isAttachmentError'], '@deepseek-ai/dsh-credentials': ['credentialKey'], '@deepseek-ai/dsh-deque': ['Deque'], '@deepseek-ai/dsh-llm': ['BlockAssembler', 'callConfigEquals', 'expandAssistantStream'], - '@deepseek-ai/dsh-session': ['SessionId'], '@deepseek-ai/dsh-session-format': ['sessionFormatLogFilename'], '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'], '@deepseek-ai/schemastery': ['default'], From 46d20f8bee8d842d77f4a3a771d1c83900546bb5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 14:31:54 +0800 Subject: [PATCH 32/37] feat(web): rank skill candidates with the shared fuzzy name ranker --- ...eb-slash-command-fuzzy-discovery.i18n.yaml | 4 +- ...08-04-web-slash-command-fuzzy-discovery.md | 10 ++- ...04-web-slash-command-fuzzy-discovery.zh.md | 10 ++- .../menu-fuzzy.expected.md | 3 + apps/web/tests/skill-invocation-policy.e2e.ts | 13 ++- .../client/ui-commands/src/client/service.ts | 73 ++--------------- .../ui-commands/tests/service.client.spec.ts | 17 +--- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 1 + .../client/ui-primitives/src/rank-by-name.ts | 79 +++++++++++++++++++ .../tests/rank-by-name.client.spec.ts | 41 ++++++++++ packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 6 +- .../tests/browser-plugin.client.spec.ts | 9 ++- 18 files changed, 178 insertions(+), 104 deletions(-) create mode 100644 apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md create mode 100644 packages/client/ui-primitives/src/rank-by-name.ts create mode 100644 packages/client/ui-primitives/tests/rank-by-name.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml index 1e3ada6894..51848d9768 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md -2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 -2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17 +2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md index 8d7fe88f8d..17ba1a1cba 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) ## Problem -The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. ## Decision -The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. +The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. -The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token. ## Alternatives considered @@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. +**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason. + ## Consequences -Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. +Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md index a96f9c984e..fe23dcb5d3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 ## 决策 -`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 +`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 -评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 ## 考虑过的替代方案 @@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 +**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。 + ## 后果 -用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 +用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。 diff --git a/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md new file mode 100644 index 0000000000..798d9510c4 --- /dev/null +++ b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Skills + - option "policy-user-only user-only · Available only to user invocation" [selected] diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 2de45d3f9b..73d928b9da 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -18,10 +18,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md') const MODE = webSnapshotMode() interface SeedSkill { @@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => { const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + + // Discovery needs no prefix: an in-order subsequence of one skill name + // ranks that skill alone, through the ranker the command group uses. + await writeComposerDraft(page, input, '/plcyusr') + await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1) + expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md']) }) }) diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 4a606fe607..055108e954 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -2,8 +2,9 @@ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then fuzzy query/position - * filtering; a host/contribution name collision fails loud. Every execute + * catalog with contributions by availability, then position filtering and + * the `/` menu's shared name ranking (ui-primitives `rankByName`); a + * host/contribution name collision fails loud. Every execute * addresses the session's agent by sessionId — sessions are always * agent-backed. */ @@ -17,6 +18,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, @@ -56,69 +58,6 @@ interface LiveState { readonly popups: Map> } -/** One fuzzy match with its stable source position. */ -interface RankedCandidate { - readonly candidate: InputTriggerCandidate - readonly index: number - readonly prefix: boolean - readonly score: number -} - -/** Extra weight for command-name starts and separator boundaries. */ -function boundaryBonus(name: string, index: number): number { - return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 -} - -/** - * Score the strongest ordered-subsequence alignment in O(name × query). - * Boundary and adjacent matches earn weight; skipped and leading characters - * cost weight. - */ -function fuzzyScore(name: string, query: string): number | undefined { - if (query === '') return 0 - if (query.length > name.length) return undefined - const noMatch = Number.NEGATIVE_INFINITY - let previous = Array(name.length).fill(noMatch) - for (let index = 0; index < name.length; index++) { - if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index - } - for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { - const current = Array(name.length).fill(noMatch) - let bestGapped = noMatch - for (let index = 0; index < name.length; index++) { - const gappedIndex = index - 2 - if (gappedIndex >= 0) { - const prior = previous[gappedIndex] ?? noMatch - if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) - } - if (name.charAt(index) !== query.charAt(queryIndex)) continue - const bonus = 1 + boundaryBonus(name, index) - const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch - if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 - if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) - } - previous = current - } - let best = noMatch - for (const score of previous) best = Math.max(best, score) - return best === noMatch ? undefined : best -} - -/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ -function fuzzyCandidates(candidates: readonly InputTriggerCandidate[], rawQuery: string): readonly InputTriggerCandidate[] { - const query = rawQuery.toLowerCase() - if (query === '') return candidates - const ranked: RankedCandidate[] = [] - candidates.forEach((candidate, index) => { - const name = candidate.name.toLowerCase() - const score = fuzzyScore(name, query) - if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) - }) - ranked.sort((left, right) => - Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) - return ranked.map(match => match.candidate) -} - /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -246,7 +185,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } } - /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: InputTriggerCandidate[] = [] @@ -262,7 +201,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return fuzzyCandidates( + return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), req.query, ) diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ae8ef7a884..fa143b0b24 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -209,24 +209,15 @@ describe('candidates', () => { expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) - it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + it('ranks rows through the shared name ranker: prefixes first, then alignment, then source order', async () => { const commands: CommandDescriptor[] = [ - { name: 'q-xylophone', description: '' }, - { name: 'qx-long', description: '' }, - { name: 'fabulous', description: '' }, - { name: 'foo-bar', description: '' }, - { name: 'zuv', description: '' }, - { name: 'zu1v', description: '' }, - { name: 'yu1v', description: '' }, - { name: 'zu12v', description: '' }, + { name: 'z_a_b', description: '' }, + { name: 'abc', description: '' }, ] const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) - await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) - await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) - await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('AB')).resolves.toEqual(['abc', 'z_a_b']) await expect(names('zzz')).resolves.toEqual([]) - await expect(names('query-longer-than-every-name')).resolves.toEqual([]) }) it('catalogs are per session: another session pulls its own key', async () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9b7c3a7285..33404cc14a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 78cb6caec665687feb1c18c65e1afe47c6e01ea1 -README.zh.md: 1450de83f88e4e33be4616ebd1f70588dd19e8d2 +README.md: c280f0b179b115f417514b40b21179eaac77ad79 +README.zh.md: e9cea4b31bdbefaa9cc076d5b6866d4e870a70d9 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 78cb6caec6..c280f0b179 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -29,7 +29,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 1450de83f8..e9cea4b31b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -29,7 +29,7 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c5f88e5eee..485ac9fc26 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -34,6 +34,7 @@ export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' +export { rankByName } from './rank-by-name.ts' export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' diff --git a/packages/client/ui-primitives/src/rank-by-name.ts b/packages/client/ui-primitives/src/rank-by-name.ts new file mode 100644 index 0000000000..97d6903728 --- /dev/null +++ b/packages/client/ui-primitives/src/rank-by-name.ts @@ -0,0 +1,79 @@ +/** + * Shared ranking for `/` menu candidates: the query must be a + * case-insensitive ordered subsequence of the candidate name. Prefix hits + * rank first, then the strongest alignment score, then the source order of + * the input. Decision record: + * .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md + */ + +/** One match with its stable source position. */ +interface Ranked { + readonly item: T + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. Undefined when the query is not a subsequence of the name. + */ +function alignmentScore(name: string, query: string): number | undefined { + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + // Sweep the previous row once: `left` is its score one character back + // (the adjacent continuation), `leftLeft` two back (the earliest gapped one). + let left = noMatch + let leftLeft = noMatch + let bestGapped = noMatch + for (const [index, prior] of previous.entries()) { + if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2) + if (name.charAt(index) === query.charAt(queryIndex)) { + const bonus = 1 + boundaryBonus(name, index) + let score = noMatch + if (left !== noMatch) score = left + bonus + 4 + if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index) + current[index] = score + } + leftLeft = left + left = prior + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** + * Rank named items by a menu query. + * @param items - candidates in source order (a host catalog, then client contributions). + * @param rawQuery - the text typed after the trigger, matched case-insensitively. + * @returns the matching items: prefix hits first, then by alignment score, + * then in source order. The input list itself for an empty query. + */ +export function rankByName(items: readonly T[], rawQuery: string): readonly T[] { + const query = rawQuery.toLowerCase() + if (query === '') return items + const ranked: Ranked[] = [] + items.forEach((item, index) => { + const name = item.name.toLowerCase() + const score = alignmentScore(name, query) + if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.item) +} diff --git a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts new file mode 100644 index 0000000000..89ed5c7733 --- /dev/null +++ b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts @@ -0,0 +1,41 @@ +/** + * Shared `/` menu ranker: case-insensitive ordered-subsequence matching, + * prefix hits first, alignment score next, source order for ties. + */ +import { describe, expect, it } from 'vitest' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' + +const named = (...names: string[]) => names.map(name => ({ name })) +const names = (items: readonly { name: string }[]) => items.map(item => item.name) + +describe('rankByName', () => { + it('returns the input list itself for an empty query', () => { + const items = named('b', 'a') + expect(rankByName(items, '')).toBe(items) + }) + + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', () => { + const items = named('q-xylophone', 'qx-long', 'fabulous', 'foo-bar', 'zuv', 'zu1v', 'yu1v', 'zu12v') + expect(names(rankByName(items, 'QX'))).toEqual(['qx-long', 'q-xylophone']) + expect(names(rankByName(items, 'fb'))).toEqual(['foo-bar', 'fabulous']) + expect(names(rankByName(items, 'uv'))).toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + expect(names(rankByName(items, 'zzz'))).toEqual([]) + expect(names(rankByName(items, 'query-longer-than-every-name'))).toEqual([]) + }) + + it('a prefix hit outranks a stronger non-prefix alignment', () => { + // 'z_a_b' aligns both characters on separator boundaries and outscores + // every non-prefix rival; a name that starts with the query still wins. + expect(names(rankByName(named('z_a_b', 'xabc'), 'ab'))).toEqual(['z_a_b', 'xabc']) + expect(names(rankByName(named('z_a_b', 'abc'), 'ab'))).toEqual(['abc', 'z_a_b']) + }) + + it('takes the stronger of an adjacent and a gapped alignment for the same character', () => { + expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab']) + }) + + it('returns the ranked items with their payload intact', () => { + const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }] + expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }]) + }) +}) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 001cfc22ab..fbda419e61 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: 6f5bd109a66b061db3ab5069b15bb4f101039e73 -README.zh.md: 61941ef1b5966d9155d724873b25e03d38d214e1 +README.md: a98194f7e590eb8a68bd329495896e9f2df1b506 +README.zh.md: c4f444f1c749f0697e8883166a0f4ea8ca2009ba diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 6f5bd109a6..a98194f7e5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name` ### What the source offers -Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. +Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results rank through the `/` menu's shared name ranker, `rankByName` from ui-primitives: the query matches a case-insensitive ordered subsequence of the skill name, prefix hits rank first, and ties keep the host order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. ### The skill tool row diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 61941ef1b5..c4f444f1c7 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### source 提供什么 -普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 +普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果经 `/` 菜单共享的名字排序器(ui-primitives 的 `rankByName`)排名:查询作为不区分大小写的有序子序列匹配 skill 名,前缀命中排最前,同分保持宿主顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 ### skill 工具行 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 44712b7286..e15a748ca1 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -35,6 +35,7 @@ import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -142,8 +143,9 @@ export function apply(ctx: ClientContext): void { const skills = await fetchCatalog(session.sessionId) // Superseded keystroke: the shared fetch stays warm, this caller yields. if (signal.aborted) return [] - return skills - .filter(skill => skill.name.startsWith(query)) + // The same ranking as the command group of this menu: case-insensitive + // ordered subsequence, prefix hits first. + return rankByName(skills, query) .map(skill => ({ name: skill.name, // The user-only marker rides the description (the menu's only diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 1c812e460e..82ca97029c 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -5,7 +5,7 @@ * the source behavior contract driven directly on the captured source with * real ClientSessionContext projections — sessionId addressing, the * session-keyed catalog cache (single-flight per key, scope-birth warm - * prewarm, connection/reset clear), startsWith filtering, RPC-failure + * prewarm, connection/reset clear), shared fuzzy name ranking, RPC-failure * rejection, pick → plain-text outcome (the plain-text-reference decision: * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md), * the synchronous @@ -167,7 +167,7 @@ describe('apply', () => { }) describe('candidates: sessionId addressing', () => { - it('lists via {sessionId} and filters by startsWith(query)', async () => { + it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => { const { list, payloads } = countingList() const { source } = await bench(list) const items = await source.candidates(proj('s1'), req('co')) @@ -177,6 +177,11 @@ describe('candidates: sessionId addressing', () => { { name: 'commit-helper', description: 'commit flow' }, { name: 'code-review', description: 'review flow' }, ]) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + // 'de' prefixes deploy and is a subsequence of code-review: the prefix ranks first. + await expect(names('de')).resolves.toEqual(['deploy', 'code-review']) + await expect(names('REV')).resolves.toEqual(['code-review']) + await expect(names('zzz')).resolves.toEqual([]) }) it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { From d0da8ccf40c16c411d062a4046975ac9cdc486f7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 14:32:38 +0800 Subject: [PATCH 33/37] fix(web): decorate slash tokens in bubbles from logged skill and command facts --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 4 +- ...web-input-machine-and-slash-pipeline.zh.md | 4 +- .../ui-chat/src/client/chat/MessageItem.tsx | 7 +- .../chat-snapshot-builder.ts | 85 ++++++++++++++++++- .../conversation-nodes/event-projection.ts | 11 +++ .../src/client/conversation-nodes/message.ts | 4 + .../tests/chat-branch-tails.client.spec.tsx | 27 +++++- ...nversation-node-definitions.client.spec.ts | 52 ++++++++++++ .../src/client/input/decorations.ts | 11 ++- .../tests/submit-machine.client.spec.ts | 8 ++ packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- .../client/GoalCommandInputView.module.css | 8 +- .../src/client/GoalCommandInputView.tsx | 13 ++- .../ui-goal/src/client/goal-command-input.ts | 5 +- .../goal-command-input-styles.client.spec.ts | 31 +++++++ .../tests/goal-command-input.client.spec.tsx | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 1 - .../src/markdown/MessageText.module.css | 9 -- .../src/markdown/MessageText.tsx | 7 -- .../ui-primitives/src/user-text.module.css | 8 ++ .../client/ui-primitives/src/user-text.tsx | 38 +++++++-- .../tests/markdown.client.spec.tsx | 9 -- .../tests/user-text-styles.client.spec.ts | 9 ++ .../tests/user-text.client.spec.tsx | 38 ++++++++- 30 files changed, 342 insertions(+), 70 deletions(-) create mode 100644 packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts delete mode 100644 packages/client/ui-primitives/src/markdown/MessageText.module.css delete mode 100644 packages/client/ui-primitives/src/markdown/MessageText.tsx diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 28ab0da3dd..89baea729d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c +2026-07-25-web-input-machine-and-slash-pipeline.md: 500f0acee97f225b0e1a05107f12a5c3ee814bfe +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 200761cc9e..500f0acee9 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. -- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. -- Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. +- Sending is the literal text (no more `` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape. - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 673d0ee4bd..cfe0781b94 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 -- 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白、draft 末尾或句尾标点——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 +- 发送即原文(不再 `` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### 每会话供数贡献与键盘私面 diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 07e9fb32a0..562d2235d1 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -160,6 +160,8 @@ function UserStyleBubble({ echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Skill names the step's `skill-invocation` injections loaded for this message. */ + skillNames?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] @@ -177,7 +179,7 @@ function UserStyleBubble({
{renderMessageImages({ images, align: 'end' })} {showBubble &&
- {projectUserText(text, referenceLabels)} + {projectUserText(text, referenceLabels, skillNames)} {rest.map((block, i) => )}
} {referenceLabels.length > 0 && ( @@ -279,6 +281,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({ content={data.content} renderMessageImages={renderMessageImages} {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }} + {...data.skillNames === undefined ? {} : { skillNames: data.skillNames }} t={t} actions={text => ( 0)) return node + const data: Record = { ...candidate.data } + if (names.length === 0) delete data.skillNames + else data.skillNames = names + return { ...candidate, data } +} + +/** + * Skill names each direct message's step loaded, keyed by message Node key. + * + * A step's `skill-invocation` injections follow the direct messages the host + * scanned for `/name` gestures and precede the step's first non-message Node + * (the assistant step, a Turn error, a command); every Node of another kind + * therefore closes the batch. Names attach to every direct message of the + * batch: the bubble decorates only the tokens its own text carries. + * @param nodes - every materialized Chat Node, in any order. + * @returns the loaded skill names per direct message key; absent for none. + */ +function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map { + const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq) + const result = new Map() + let batchMessages: string[] = [] + let batchNames: string[] = [] + const flush = (): void => { + if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames) + batchMessages = [] + batchNames = [] + } + for (const node of ordered) { + const candidate = node as ChatNode + if (candidate.kind === 'user' || candidate.kind === 'steering') { + batchMessages.push(node.key) + continue + } + if (candidate.kind !== 'context') { + flush() + continue + } + const name = skillInvocationName(candidate.data.source) + if (name !== null && !batchNames.includes(name)) batchNames.push(name) + } + flush() + return result +} + +/** Attaches each direct message's step-loaded skill names to its Node. */ +class SkillNameProjector { + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + const names = skillNamesByMessage(nodes) + return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS)) + } + + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const byKey = new Map(upserts.map(node => [node.key, node])) + const all = new Map(store.values().map(node => [node.key, node])) + for (const [key, node] of byKey) all.set(key, node) + const names = skillNamesByMessage([...all.values()]) + for (const [key, node] of all) { + const candidate = node as ChatNode + if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue + const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS) + if (next !== node || byKey.has(key)) byKey.set(key, next) + } + return [...byKey.values()] + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -756,6 +834,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) @@ -785,7 +864,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts index 0d909cdcdd..1695a467fe 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts @@ -88,6 +88,17 @@ export function sessionRecallLabels(source: unknown): string[] { return collect(record, 'references', 'label') } +/** + * Read the skill name a durable skill-invocation injection loaded. + * @param source - Logged `user/message` source. + * @returns The skill name, or null for every other source. + */ +export function skillInvocationName(source: unknown): string | null { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'skill-invocation') return null + return readString(record, 'name') +} + /** * Classify finalized Assistant content for Chat rendering. * @param content - Core content blocks. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 1927a468c5..b7806477a6 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -9,11 +9,15 @@ import { contextForm, contextProvenance } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } interface ReferencedSteeringMessageNode extends SteeringMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7bcd9373b2..4c817738d7 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -48,10 +48,11 @@ interface MessageItemProps { readonly node: ConversationNode readonly t: ChatNodeViewProps['t'] readonly referenceLabels?: readonly string[] + readonly skillNames?: readonly string[] } /** Legacy-node fixture adapter for the independently registered renderers. */ -function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) { +function MessageItem({ node, t: translate, referenceLabels, skillNames }: MessageItemProps) { const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind const viewNode: ChatConversationViewNode = { key: `fixture:${node.kind}:${node.seq}`, @@ -63,8 +64,12 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) visibility: 'visible', data: node.kind === 'model-retry' ? { attempts: [node], current: node } - : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined - ? { ...node, referenceLabels } + : (node.kind === 'user' || node.kind === 'steering') && (referenceLabels !== undefined || skillNames !== undefined) + ? { + ...node, + ...(referenceLabels === undefined ? {} : { referenceLabels }), + ...(skillNames === undefined ? {} : { skillNames }), + } : node, } const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps @@ -141,6 +146,22 @@ describe('MessageItem arms', () => { expect(view.container.textContent).toContain('README.md, please.') }) + it('decorates a slash token as a skill chip only when the step resolved that skill', () => { + const message = { + kind: 'user' as const, + seq: 1, + time: 1_000, + content: [{ type: 'text', text: '/123 then /demo-skill go' }] as never, + source: null, + } + const plain = render() + expect(plain.container.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = render() + const chips = [...resolved.container.querySelectorAll('[data-ref-chip="skill"]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/demo-skill']) + expect(resolved.container.textContent).toContain('/123 then ') + }) + it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f401455a32..50bcb83297 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1766,6 +1766,58 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates a direct message with the skill invocations injected for its step', () => { + const skillInvocation = (id: string) => ({ + ...textMessage(id, 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }) + const instructions = (id: string) => ({ + ...textMessage(id, 'workspace rules'), + source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] }, + }) + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }), + at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }), + at(6, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('answer-1', 'done'), + }, { surfaceOp: 'append' }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(9, 'turn/start', { turn: 2 }), + at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }), + ]) + + const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users).toHaveLength(2) + expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] }) + expect(users[1]?.data).not.toHaveProperty('skillNames') + }) + + it('updates an already published direct node when its skill injection arrives', () => { + const value = assembler([ + at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + ]) + const before = node(snapshot(value), 'user') + expect(before?.data).not.toHaveProperty('skillNames') + + value.append(at(2, 'user/message', { + ...textMessage('skill-body', 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' })) + value.flush() + + const after = node(snapshot(value), 'user') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] }) + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index e20a006dac..64a19c2828 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -23,12 +23,20 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g +/** + * What may follow a `/name` token: whitespace or the draft end, optionally + * after trailing sentence punctuation. The host skill gesture + * (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md` + * is a path, never a reference. + */ +const SLASH_TOKEN_END_RE = /^[.,;:!?,。;:!?]*(?:\s|$)/ /** * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member. + * exact lexicon member; a `/name` token must end at whitespace, the draft + * end, or trailing sentence punctuation ('/name/x' is a path). * @param draft - draft text. * @param lexicon - per-trigger name lists (a missing trigger scans nothing). * @returns matched ranges in draft order. @@ -44,6 +52,7 @@ export function scanTextRefs( while ((m = TEXT_REF_RE.exec(draft)) !== null) { const trigger = m[2] as '/' | '@' const name = m[3] ?? '' + if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue if (lexicon.get(trigger)?.includes(name)) { const start = m.index + (m[1]?.length ?? 0) out.push({ start, end: start + 1 + name.length, trigger }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index af1e72f47e..faf1f4e33f 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -347,6 +347,14 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/research @goal', lexicon)).toEqual([]) }) + it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => { + expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([]) + }) + + it('a "/" token may end at trailing punctuation before whitespace', () => { + expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }]) + }) + it('word boundary: a trigger glued to text never matches', () => { expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([]) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 096dec8b0d..1290b0416d 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020 -README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1 +README.md: 8556575b00e1d3073f251d83f3dee0bafb4c8969 +README.zh.md: 22d0417bc2d37719150ec46aad849ad2bce8b460 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 1a2c65759f..8556575b00 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index feebeaff0b..22d0417bc2 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css index f403730eff..42fff6430e 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -23,10 +23,10 @@ border-radius: 22px; background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); - font: var(--dsw-font-markdown-code); - /* The command echo is a user message, not dense code-in-prose: keep the - code family from the token but read at the body size, riding the - Settings font-size axis like the user bubble (14/22 at the default). */ + /* The command echo is a user message: it inherits the body face like the + user bubble and rides the same Settings font-size axis (14/22 at the + default); the `/goal` command chip, set in the code face by the shared + projection, is what marks the line as a command. */ font-size: var(--dsh-content-font-size, 14px); line-height: calc(22px + var(--dsh-content-font-delta, 0px)); white-space: pre-wrap; diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 6d2345042b..43f3dc30b2 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -1,14 +1,19 @@ import { memo } from 'react' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { GoalCommandInputData } from './goal-command-input.ts' +import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts' import css from './GoalCommandInputView.module.css' type GoalCommandInputViewProps = PropsRuntime<'conversation.chat.node', 'command-input'> & PropsLocale<'goal'> -/** Right-aligned `/goal` input bubble without ordinary message actions. */ +/** + * Right-aligned `/goal` input bubble without ordinary message actions. The + * echoed line decorates its leading `/goal` token as a command chip — the run + * this Node projects is the fact that the token was a command — and keeps + * the objective as plain text. + */ export const GoalCommandInputView = memo(function GoalCommandInputView({ node, t, }: GoalCommandInputViewProps) { @@ -22,7 +27,7 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({ >
- + {projectUserText(data.text, [], [GOAL_COMMAND], 'command')}
diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts index 526dc0958d..742959a349 100644 --- a/packages/client/ui-goal/src/client/goal-command-input.ts +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -5,6 +5,9 @@ import type { ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** The command name whose runs this projection owns. */ +export const GOAL_COMMAND = 'goal' + /** Goal-owned human command input projected independently of model messages. */ export interface GoalCommandInputData { readonly commandId: CommandId @@ -36,7 +39,7 @@ export function goalCommandText(event: SessionEvent<'command/run'>): string { export const goalCommandInputDefinition: ConversationNodeDefinition = { kind: 'goal-command-input', target: 'chat', - match: event => event.type === 'command/run' && event.data.name === 'goal' + match: event => event.type === 'command/run' && event.data.name === GOAL_COMMAND ? { id: String(event.data.commandId), role: 'start' } : null, start: (_context, match) => { diff --git a/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts new file mode 100644 index 0000000000..41b8fcaa4f --- /dev/null +++ b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts @@ -0,0 +1,31 @@ +/** + * The command-input bubble's typography as CSS text. jsdom has no layout, so + * this reads the declarations that make the bubble share the user bubble's + * face and size axis: the shared projection sets the `/goal` chip in the code + * face, so the bubble itself must not pin a different family. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + fileURLToPath(new URL('../src/client/GoalCommandInputView.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(css) + if (rule === null) throw new Error(`GoalCommandInputView.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('GoalCommandInputView.module.css typography', () => { + it('types the command bubble like the user bubble: no family override, same size axis', () => { + const bubble = declarations('.bubble') + expect(bubble.some(declaration => /^font(-family)?:/.test(declaration))).toBe(false) + expect(bubble).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size, 14px)', + 'line-height: calc(22px + var(--dsh-content-font-delta, 0px))', + ])) + }) +}) diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d149cd7f4f..6f12c5e456 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -133,5 +133,8 @@ describe('goal command input projection', () => { expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() + // The executed command token reads as a reference chip; the objective stays plain text. + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 33404cc14a..0036b684de 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: c280f0b179b115f417514b40b21179eaac77ad79 -README.zh.md: e9cea4b31bdbefaa9cc076d5b6866d4e870a70d9 +README.md: 758132bc9c193594a9407a105820633c75e58880 +README.zh.md: 8b3fffa9e73464d526c3a2f11b212797c2707c69 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c280f0b179..758132bc9c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Rendering agent output -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e9cea4b31b..8b3fffa9e7 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -33,7 +33,7 @@ kind: "package-library" ### 渲染 agent 输出 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 ### 本地化文案 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 485ac9fc26..44b9e8e2c5 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -58,7 +58,6 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx' -export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MessageText.module.css b/packages/client/ui-primitives/src/markdown/MessageText.module.css deleted file mode 100644 index c6a9c9b245..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.text { - white-space: pre-wrap; - word-break: break-word; - /* Font metrics inherit from the consumer's container (bubble 16/24, - assistant flow 16/28) — a generic text primitive must not pin its own - size, or every consumer's line grid breaks (44px bubble spec regression). */ - font-size: inherit; - line-height: inherit; -} diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx deleted file mode 100644 index cafe9ab3c0..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText. - -import css from './MessageText.module.css' - -export function MessageText({ text }: { text: string }) { - return
{text}
-} diff --git a/packages/client/ui-primitives/src/user-text.module.css b/packages/client/ui-primitives/src/user-text.module.css index 3e6f8ca4f0..3654d6f990 100644 --- a/packages/client/ui-primitives/src/user-text.module.css +++ b/packages/client/ui-primitives/src/user-text.module.css @@ -20,6 +20,14 @@ white-space: nowrap; } +/* Skill and command tokens read as code: the theme's code family at the + consumer's own size and line height, so the chip sits on the bubble line + like the plain runs around it. `@` chips keep the body face — a file or + session name is a label, not code. */ +.slashChip { + font-family: var(--dsw-font-markdown-code-font-family); +} + /* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own font: 1em keeps the glyph at the text's size in the bubble (14px + user setting), the queue preview's fixed 13px line, and any future consumer — a diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 494f49a868..8348b0a930 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -4,17 +4,27 @@ * only, and every part renders inline so a single-line message never breaks * across lines. Three decoration sources, by precedence: the wire session form * `@[label](dsh-session:...)` folds to its label; exact session labels - * supplied by an adjacent recall decorate their bare `@label` mention; and - * plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent - * tokens were validated at compose time). + * supplied by an adjacent recall decorate their bare `@label` mention; plain + * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` + * token decorates only when the caller names it: a skill the host actually + * loaded for that message (ui-chat reads the step's `skill-invocation` + * injections) or the command a command-input bubble echoes, so `/123` or a + * stray `/word` stays plain text. A `/name` + * token is whitespace-bounded like the host skill gesture + * (`dsh-tool-skill`), optionally before trailing sentence punctuation, so + * slash paths (`/nfs-hg/xxx`, `/plan.md`) stay plain even for a loaded name. */ import type { ReactNode } from 'react' +import clsx from 'clsx' import { ReferenceIcon } from './ReferenceIcon.tsx' import css from './user-text.module.css' /** The wire form a session chip serializes to; label is the display text. */ const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu +/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */ +const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u + interface DecorationRange { readonly start: number readonly end: number @@ -29,9 +39,18 @@ interface DecorationRange { * Split one sent text into inline plain runs and reference chips. * @param text - the logged model text of the message or queue row. * @param sessionLabels - exact session mention labels associated by an adjacent recall. + * @param slashNames - names a `/name` token may decorate as: the skills the + * host loaded for this message, or the command a command bubble echoes + * (unsent queue rows pass none). + * @param slashKind - the chip kind those tokens render as. * @returns inline nodes covering the whole text. */ -export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { +export function projectUserText( + text: string, + sessionLabels: readonly string[], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +): ReactNode { const ranges: DecorationRange[] = [] SESSION_WIRE_RE.lastIndex = 0 let wire: RegExpExecArray | null @@ -52,15 +71,18 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): start = text.indexOf(label, start + label.length) } } - const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu + // The `/` alternative's lookahead admits the same trailing punctuation set + // TRAILING_PUNCTUATION_RE strips. + const re = /(^|\s)(\/[\w-]+(?=[.,;:!?,。;:!?]*(?:\s|$))|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start const rawLabel = m[2] as string // non-optional alternation capture const label = rawLabel.startsWith('@"') ? rawLabel - : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + : rawLabel.replace(TRAILING_PUNCTUATION_RE, '') if (label.length <= 1) continue + if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) } const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1 @@ -88,8 +110,8 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): parts.push( {referenceKind !== undefined && ( diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index eb6b450924..a16d0464cb 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,21 +1,12 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) -describe('MessageText', () => { - it('renders the text verbatim', () => { - const { container } = render() - expect(container.textContent).toBe('# line1\n`line2`') - expect(container.querySelector('h1')).toBeNull() - }) -}) - describe('MarkdownText', () => { it('renders CommonMark and GFM elements as semantic DOM', () => { const markdown = [ diff --git a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts index d80d4f5c13..575b705f12 100644 --- a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts @@ -24,4 +24,13 @@ describe('user-text.module.css font-size axis', () => { 'height: 1em', ])) }) + + it('sets slash chips in the code face at the consumer size', () => { + // Skill and command tokens read as code — the family the theme publishes + // for code — while the size and line height stay the consumer's, so the + // chip rides the bubble line like the plain runs around it. + const slashChip = declarations('.slashChip') + expect(slashChip.some(declaration => /^font-family: var\(--dsw-font-/.test(declaration))).toBe(true) + expect(slashChip.some(declaration => /^(font|font-size|line-height):/.test(declaration))).toBe(false) + }) }) diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-primitives/tests/user-text.client.spec.tsx index 6e1c2e309f..2c14889ad5 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-primitives/tests/user-text.client.spec.tsx @@ -8,12 +8,17 @@ import { describe, expect, it } from 'vitest' import { render } from '@testing-library/react' import { projectUserText } from '../src/user-text.tsx' -const project = (text: string, labels: readonly string[] = []) => - render(
{projectUserText(text, labels)}
).container.querySelector('[data-host]')! +const project = ( + text: string, + labels: readonly string[] = [], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +) => + render(
{projectUserText(text, labels, slashNames, slashKind)}
).container.querySelector('[data-host]')! describe('projectUserText', () => { it('keeps a decorated single-line message on one line: every part is inline', () => { - const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试']) + const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test']) expect(host.querySelectorAll('div').length).toBe(0) expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试') const chips = host.querySelectorAll('[data-ref-chip]') @@ -56,12 +61,37 @@ describe('projectUserText', () => { }) it('strips trailing punctuation and skips degenerate tokens', () => { - const host = project('用 /plan。 试试 @。') + const host = project('用 /plan。 试试 @。', [], ['plan']) const chips = [...host.querySelectorAll('[data-ref-chip]')] expect(chips.map(c => c.textContent)).toEqual(['/plan']) expect(host.textContent).toBe('用 /plan。 试试 @。') }) + it('decorates a slash token only when the host resolved it as a skill in that step', () => { + const bare = project('/123') + expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(bare.textContent).toBe('/123') + const unresolved = project('用 /plan 看看') + expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = project('用 /plan 看看', [], ['plan']) + expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent])) + .toEqual([['skill', '/plan']]) + }) + + it('marks a resolved slash token as a command chip when the caller says so', () => { + const host = project('/goal ship it\nsecond line', [], ['goal'], 'command') + const chips = [...host.querySelectorAll('[data-ref-chip]')] + expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']]) + expect(host.textContent).toBe('/goal ship it\nsecond line') + }) + + it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace or trailing punctuation', () => { + const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md' + const host = project(text, [], ['nfs-hg', 'root-dir', 'plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(host.textContent).toBe(text) + }) + it('prefers the longer recall label when one nests inside another', () => { const host = project('@会话一 收尾', ['会话', '会话一']) const chips = [...host.querySelectorAll('[data-ref-chip="session"]')] From d976849a6be8489e78b3f0102b81cee5a17856d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 14:55:41 +0800 Subject: [PATCH 34/37] fix(web): expect the goal command bubble to share the body face --- apps/web/tests/goal-command-presentation.e2e.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 1139b878c6..70ad37a799 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => { const typography = await commandInput.evaluate((element) => { const bubble = element.firstElementChild?.firstElementChild if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing') + const chip = bubble.querySelector('[data-ref-chip="command"]') + if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing') const rootStyle = getComputedStyle(element) const bubbleStyle = getComputedStyle(bubble) + const chipStyle = getComputedStyle(chip) return { fontFamily: bubbleStyle.fontFamily, parentFontFamily: rootStyle.fontFamily, fontSize: bubbleStyle.fontSize, lineHeight: bubbleStyle.lineHeight, + chipText: chip.textContent, + chipFontFamily: chipStyle.fontFamily, + chipFontSize: chipStyle.fontSize, } }) - expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' }) - expect(typography.fontFamily).not.toBe(typography.parentFontFamily) + expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' }) + // The bubble reads in the body face like a user bubble; only the command + // chip carries the code face that marks the echoed token as a command. + expect(typography.fontFamily).toBe(typography.parentFontFamily) + expect(typography.chipFontFamily).not.toBe(typography.fontFamily) const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1) From 1b470211ada8956ed8afbbc7943d1e8b29383572 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 15:15:51 +0800 Subject: [PATCH 35/37] fix(web): address review: index skill-name batches, bound slash tokens at whitespace, chip only the leading goal token --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 2 +- ...web-input-machine-and-slash-pipeline.zh.md | 2 +- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- .../chat-snapshot-builder.ts | 217 ++++++++++++++---- .../tests/skill-name-projector.client.spec.ts | 88 +++++++ .../src/client/input/decorations.ts | 13 +- .../tests/submit-machine.client.spec.ts | 4 +- .../src/client/GoalCommandInputView.tsx | 12 +- .../tests/goal-command-input.client.spec.tsx | 31 +++ .../client/ui-primitives/src/user-text.tsx | 20 +- .../tests/user-text.client.spec.tsx | 9 +- 14 files changed, 332 insertions(+), 82 deletions(-) create mode 100644 packages/client/ui-chat/tests/skill-name-projector.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 89baea729d..fbb3f653a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 500f0acee97f225b0e1a05107f12a5c3ee814bfe -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf +2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 500f0acee9..1b9e9d95b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -67,7 +67,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. -- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. - Sending is the literal text (no more `` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape. - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index cfe0781b94..c9bee217da 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -67,7 +67,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白、draft 末尾或句尾标点——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 - 发送即原文(不再 `` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 671ca8f796..706794e459 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b -2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99 +2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf +2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index d2b8e30d77..4063ad647c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events, ## Decision -`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. +`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal. `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism). @@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.* -**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. +**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index c1542d75fa..6b062e155c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 决策 -`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 +`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。 @@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。* -**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 +**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index ec36b8dc0f..3495b565ca 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -521,67 +521,192 @@ function withSkillNames( return { ...candidate, data } } -/** - * Skill names each direct message's step loaded, keyed by message Node key. - * - * A step's `skill-invocation` injections follow the direct messages the host - * scanned for `/name` gestures and precede the step's first non-message Node - * (the assistant step, a Turn error, a command); every Node of another kind - * therefore closes the batch. Names attach to every direct message of the - * batch: the bubble decorates only the tokens its own text carries. - * @param nodes - every materialized Chat Node, in any order. - * @returns the loaded skill names per direct message key; absent for none. - */ -function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map { - const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq) - const result = new Map() - let batchMessages: string[] = [] - let batchNames: string[] = [] - const flush = (): void => { - if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames) - batchMessages = [] - batchNames = [] - } - for (const node of ordered) { - const candidate = node as ChatNode - if (candidate.kind === 'user' || candidate.kind === 'steering') { - batchMessages.push(node.key) - continue - } - if (candidate.kind !== 'context') { - flush() - continue - } - const name = skillInvocationName(candidate.data.source) - if (name !== null && !batchNames.includes(name)) batchNames.push(name) - } - flush() - return result +/** What skill-name batches are made of; every other Node is transparent. */ +type SlashEntryKind = 'message' | 'skill' | 'boundary' + +interface SlashEntry { + readonly key: string + readonly seq: number + readonly kind: SlashEntryKind + /** The injected skill name of a `skill` entry. */ + readonly name: string | null } -/** Attaches each direct message's step-loaded skill names to its Node. */ -class SkillNameProjector { +/** + * Classify one Node for batching: a direct message, a `skill-invocation` + * context, or a boundary of any other kind. A context that injects no skill + * (workspace rules, the catalog, a recall) is transparent and yields null. + */ +function slashEntryOf(node: ChatConversationViewNode): SlashEntry | null { + const candidate = node as ChatNode + if (candidate.kind === 'user' || candidate.kind === 'steering') { + return { key: node.key, seq: node.anchorSeq, kind: 'message', name: null } + } + if (candidate.kind === 'context') { + const name = skillInvocationName(candidate.data.source) + return name === null ? null : { key: node.key, seq: node.anchorSeq, kind: 'skill', name } + } + return { key: node.key, seq: node.anchorSeq, kind: 'boundary', name: null } +} + +function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean { + return left.seq === right.seq && left.kind === right.kind && left.name === right.name +} + +/** + * Attaches each direct message's step-loaded skill names to its Node. + * + * A step's `skill-invocation` injections follow the direct messages the host + * scanned for `/name` gestures and precede the step's first Node of any other + * kind, so every non-message, non-context Node closes a batch. Every ended + * Turn publishes its `turn-tail` Node on `turn/end` whatever the reason, so a + * batch never spans Turns, and `step/start` precedes the direct message in + * the log, so no boundary separates a message from its injections. Names + * attach to every direct message of the batch: the bubble decorates only the + * tokens its own text carries. + * + * The index holds only messages, skill injections, and boundaries, ordered by + * `anchorSeq`. An apply re-reads just the batches around the Nodes whose + * classification changed and never scans the store, so an assistant + * streaming frame costs nothing here (the append hot path never scans the + * Chat Nodes). + */ +export class SkillNameProjector { + private readonly entries = new Map() + /** Every indexed entry in `anchorSeq` order. */ + private sorted: SlashEntry[] = [] + + /** + * Rebuild the index from a whole Node set and attach names to its messages. + * @param nodes - every materialized Chat Node, in any order. + * @returns the same Nodes, direct messages carrying their batch's names. + */ replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { - const names = skillNamesByMessage(nodes) + this.entries.clear() + this.sorted = [] + for (const node of nodes) { + const entry = slashEntryOf(node) + if (entry === null) continue + this.entries.set(entry.key, entry) + this.sorted.push(entry) + } + this.sorted.sort((left, right) => left.seq - right.seq) + const names = new Map() + for (let index = 0; index < this.sorted.length; index++) { + if (this.sorted[index]?.kind === 'boundary') continue + const end = this.runEnd(index) + this.assignRun(index, end, names) + index = end + } return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS)) } + /** + * Fold one incremental upsert set: re-read only the batches around the + * Nodes whose classification changed. + * @param upserts - the changed Nodes. + * @param store - the resident Nodes, read by key for the messages of an affected batch. + * @returns the upserts plus any resident message whose names changed. + */ apply( upserts: readonly ChatConversationViewNode[], store: ChatNodeStore, ): readonly ChatConversationViewNode[] { + const dirty: number[] = [] + for (const node of upserts) { + const next = slashEntryOf(node) + const previous = this.entries.get(node.key) + if (previous !== undefined) { + if (next !== null && sameSlashEntry(previous, next)) continue + this.remove(previous) + dirty.push(previous.seq) + } + if (next === null) continue + this.insert(next) + dirty.push(next.seq) + } + if (dirty.length === 0) return upserts + const names = new Map() + for (const seq of dirty) this.collectAround(seq, names) const byKey = new Map(upserts.map(node => [node.key, node])) - const all = new Map(store.values().map(node => [node.key, node])) - for (const [key, node] of byKey) all.set(key, node) - const names = skillNamesByMessage([...all.values()]) - for (const [key, node] of all) { - const candidate = node as ChatNode - if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue - const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS) + for (const [key, list] of names) { + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + const next = withSkillNames(node, list) if (next !== node || byKey.has(key)) byKey.set(key, next) } return [...byKey.values()] } + + private insert(entry: SlashEntry): void { + this.sorted.splice(this.lowerBound(entry.seq), 0, entry) + this.entries.set(entry.key, entry) + } + + private remove(entry: SlashEntry): void { + this.sorted.splice(this.sorted.indexOf(entry), 1) + this.entries.delete(entry.key) + } + + /** First index whose seq is at least `seq`. */ + private lowerBound(seq: number): number { + let low = 0 + let high = this.sorted.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((this.sorted[middle]?.seq ?? Number.POSITIVE_INFINITY) < seq) low = middle + 1 + else high = middle + } + return low + } + + /** Last index of the boundary-free run containing `index`. */ + private runEnd(index: number): number { + let end = index + while (end + 1 < this.sorted.length && this.sorted[end + 1]?.kind !== 'boundary') end++ + return end + } + + /** First index of the boundary-free run containing `index`. */ + private runStart(index: number): number { + let start = index + while (start - 1 >= 0 && this.sorted[start - 1]?.kind !== 'boundary') start-- + return start + } + + /** Record the names every message of the run `[start, end]` carries. */ + private assignRun(start: number, end: number, names: Map): void { + const list: string[] = [] + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'skill' && entry.name !== null && !list.includes(entry.name)) list.push(entry.name) + } + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'message') names.set(entry.key, list) + } + } + + /** + * Re-read the run(s) around one changed seq: the run holding a message or + * skill entry, or — for a boundary, or a seq that left the index — the runs + * on both sides of that position. + */ + private collectAround(seq: number, names: Map): void { + const at = this.lowerBound(seq) + const here = this.sorted[at] + if (here !== undefined && here.seq === seq && here.kind !== 'boundary') { + this.assignRun(this.runStart(at), this.runEnd(at), names) + return + } + if (at - 1 >= 0 && this.sorted[at - 1]?.kind !== 'boundary') { + this.assignRun(this.runStart(at - 1), at - 1, names) + } + const right = here !== undefined && here.seq === seq ? at + 1 : at + if (right < this.sorted.length && this.sorted[right]?.kind !== 'boundary') { + this.assignRun(right, this.runEnd(right), names) + } + } } interface LegacyContribution { diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts new file mode 100644 index 0000000000..0be8dca394 --- /dev/null +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -0,0 +1,88 @@ +/** + * SkillNameProjector: the step's `skill-invocation` injections attach to the + * direct messages of the same batch, incrementally — an assistant-only apply + * must neither scan the store nor re-emit message Nodes. + */ +import { describe, expect, it } from 'vitest' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import type { ChatNodeStore } from '../src/client/contract/snapshot.ts' +import { SkillNameProjector } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' + +function viewNode(kind: string, seq: number, data: Record): ChatConversationViewNode { + return { + key: `${kind}:${seq}`, + kind, + id: String(seq), + target: 'chat', + anchorSeq: seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { kind, seq, time: seq, ...data }, + } as unknown as ChatConversationViewNode +} +const user = (seq: number, text: string) => + viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } }) +const skill = (seq: number, name: string) => + viewNode('context', seq, { content: [], source: { kind: 'skill-invocation', name, form: 'instructions' } }) +const instructions = (seq: number) => + viewNode('context', seq, { content: [], source: { kind: 'agent-instructions', changes: [] } }) +const assistant = (seq: number, text = 'answer') => viewNode('assistant-step', seq, { text }) + +function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { valuesCalls: number } { + const byKey = new Map(nodes.map(node => [node.key, node])) + const store = { + valuesCalls: 0, + get: (key: string) => byKey.get(key), + values: () => { + store.valuesCalls += 1 + return [...byKey.values()] + }, + source: () => { throw new Error('unused') }, + processSource: () => { throw new Error('unused') }, + } + return store +} + +const names = (node: ChatConversationViewNode | undefined) => + (node?.data as { skillNames?: readonly string[] } | undefined)?.skillNames + +describe('SkillNameProjector', () => { + it('attaches a batch\'s injected skill names on replace and leaves other Nodes untouched', () => { + const projector = new SkillNameProjector() + const answer = assistant(6) + const out = projector.replace([user(2, '/demo go'), instructions(4), skill(5, 'demo'), answer, user(10, '/demo later?')]) + expect(names(out[0])).toEqual(['demo']) + expect(names(out[4])).toBeUndefined() + expect(out[3]).toBe(answer) + }) + + it('an assistant-only apply neither scans the store nor re-emits message Nodes', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + const store = storeOf(replaced) + const frame = assistant(6, 'answer grows') + const out = projector.apply([frame], store) + expect(out).toEqual([frame]) + expect(store.valuesCalls).toBe(0) + }) + + it('a late skill injection updates only the direct messages of its batch', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')]) + const store = storeOf(replaced) + const injection = skill(3, 'demo') + const out = projector.apply([injection], store) + expect(out.map(node => node.key).sort()).toEqual(['context:3', 'user:2']) + expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo']) + }) + + it('a boundary arriving inside a batch splits it and drops the names past it', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')]) + expect(names(replaced[2])).toEqual(['demo']) + const store = storeOf(replaced) + const out = projector.apply([assistant(6)], store) + expect(out.map(node => node.key).sort()).toEqual(['assistant-step:6', 'user:8']) + expect(names(out.find(node => node.key === 'user:8'))).toBeUndefined() + }) +}) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 64a19c2828..e221abf64b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -24,19 +24,18 @@ export interface TextRefRange { const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g /** - * What may follow a `/name` token: whitespace or the draft end, optionally - * after trailing sentence punctuation. The host skill gesture - * (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md` - * is a path, never a reference. + * What may follow a `/name` token: whitespace or the draft end, the boundary + * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`, + * `/plan.md`, and `/plan。` are prose, never a reference. */ -const SLASH_TOKEN_END_RE = /^[.,;:!?,。;:!?]*(?:\s|$)/ +const SLASH_TOKEN_END_RE = /^(?:\s|$)/ /** * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member; a `/name` token must end at whitespace, the draft - * end, or trailing sentence punctuation ('/name/x' is a path). + * exact lexicon member; a `/name` token must end at whitespace or the draft + * end ('/name/x' is a path, '/name。' is prose). * @param draft - draft text. * @param lexicon - per-trigger name lists (a missing trigger scans nothing). * @returns matched ranges in draft order. diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index faf1f4e33f..d2ba36729b 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -351,8 +351,8 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([]) }) - it('a "/" token may end at trailing punctuation before whitespace', () => { - expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }]) + it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => { + expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([]) }) it('word boundary: a trigger glued to text never matches', () => { diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 43f3dc30b2..8d9742d45d 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -11,13 +11,18 @@ type GoalCommandInputViewProps = /** * Right-aligned `/goal` input bubble without ordinary message actions. The * echoed line decorates its leading `/goal` token as a command chip — the run - * this Node projects is the fact that the token was a command — and keeps - * the objective as plain text. + * this Node projects is the fact that that token was a command — and keeps + * the objective, `/goal` mentions included, as plain text. */ export const GoalCommandInputView = memo(function GoalCommandInputView({ node, t, }: GoalCommandInputViewProps) { const data: GoalCommandInputData = node.data + // Only the leading token is the executed command; the rest of the line is + // the objective, where a further `/goal` is prose. + const split = data.text.search(/\s/u) + const head = split === -1 ? data.text : data.text.slice(0, split) + const rest = split === -1 ? '' : data.text.slice(split) return (
- {projectUserText(data.text, [], [GOAL_COMMAND], 'command')} + {projectUserText(head, [], [GOAL_COMMAND], 'command')} + {rest !== '' && projectUserText(rest, [])}
diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index 6f12c5e456..ad03572d4c 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -137,4 +137,35 @@ describe('goal command input projection', () => { const chips = [...bubble.querySelectorAll('[data-ref-chip]')] expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) }) + + it('renders a bare /goal as one command chip and nothing else', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:bare', + data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal') + expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal']) + }) + + it('decorates only the leading command token: a /goal inside the objective stays plain', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:two', + data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal 检查 /goal 的语法') + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/goal']) + }) }) diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 8348b0a930..f0b89cfbb4 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -2,17 +2,17 @@ * Display projection of reference forms in sent user text (bubble and queue * rows). The logged model text remains the single truth; this is presentation * only, and every part renders inline so a single-line message never breaks - * across lines. Three decoration sources, by precedence: the wire session form + * across lines. Four decoration sources, by precedence: the wire session form * `@[label](dsh-session:...)` folds to its label; exact session labels * supplied by an adjacent recall decorate their bare `@label` mention; plain * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` - * token decorates only when the caller names it: a skill the host actually + * token decorates only when the caller names it — a skill the host actually * loaded for that message (ui-chat reads the step's `skill-invocation` - * injections) or the command a command-input bubble echoes, so `/123` or a - * stray `/word` stays plain text. A `/name` - * token is whitespace-bounded like the host skill gesture - * (`dsh-tool-skill`), optionally before trailing sentence punctuation, so - * slash paths (`/nfs-hg/xxx`, `/plan.md`) stay plain even for a loaded name. + * injections) or the command a command-input bubble echoes — so `/123` or a + * stray `/word` stays plain text. A `/name` token is whitespace-bounded like + * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the + * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued + * tokens (`/plan。`) stay plain even for a loaded name. */ import type { ReactNode } from 'react' import clsx from 'clsx' @@ -71,9 +71,9 @@ export function projectUserText( start = text.indexOf(label, start + label.length) } } - // The `/` alternative's lookahead admits the same trailing punctuation set - // TRAILING_PUNCTUATION_RE strips. - const re = /(^|\s)(\/[\w-]+(?=[.,;:!?,。;:!?]*(?:\s|$))|@"[^"\n]+"|@[^\s]+)/gu + // A `/` token ends at whitespace or the text end like the host skill + // gesture; only `@` tokens shed sentence punctuation below. + const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-primitives/tests/user-text.client.spec.tsx index 2c14889ad5..b65bb8f43d 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-primitives/tests/user-text.client.spec.tsx @@ -60,10 +60,11 @@ describe('projectUserText', () => { expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2) }) - it('strips trailing punctuation and skips degenerate tokens', () => { + it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => { + // The host skill gesture ends at whitespace or the text end, so `/plan。` + // never loads a skill; the bubble must not suggest otherwise. const host = project('用 /plan。 试试 @。', [], ['plan']) - const chips = [...host.querySelectorAll('[data-ref-chip]')] - expect(chips.map(c => c.textContent)).toEqual(['/plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) expect(host.textContent).toBe('用 /plan。 试试 @。') }) @@ -85,7 +86,7 @@ describe('projectUserText', () => { expect(host.textContent).toBe('/goal ship it\nsecond line') }) - it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace or trailing punctuation', () => { + it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => { const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md' const host = project(text, [], ['nfs-hg', 'root-dir', 'plan']) expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) From 58ec1f08677359f2b5edb47f62b0777972de0a91 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 4 Sep 2026 15:41:25 +0800 Subject: [PATCH 36/37] refactor(file-upload): keep storage helpers and the HTTP route package-private Stop re-exporting the verbatim file-store helpers from attachment-local, move the raw-byte upload route handler into a non-entry module of file-upload, and drop the redundant hook type re-exports from the Client entry. The route test moves next to the module it exercises. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../attachment/attachment-local/src/index.ts | 3 - .../client/file-upload/src/client/contract.ts | 2 - .../client/file-upload/src/client/index.ts | 8 +- .../client/file-upload/src/client/runtime.ts | 9 +-- packages/client/file-upload/src/http-route.ts | 81 +++++++++++++++++++ packages/client/file-upload/src/index.ts | 79 +----------------- .../tests/file-upload-http.host.spec.ts | 3 +- .../tests/file-upload.client.spec.ts | 3 +- .../client/file-upload/tsconfig.host.json | 2 +- 12 files changed, 96 insertions(+), 102 deletions(-) create mode 100644 packages/client/file-upload/src/http-route.ts rename packages/{api/session-controller => client/file-upload}/tests/file-upload-http.host.spec.ts (97%) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 609c5c379f..df25b46feb 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 5dcec11452e12400442d5dd539b6bc7e2491ed87 -config-catalog.zh.md: cde0be16f3f433cc24076029b671fde3783e6648 +config-catalog.md: af72b832723423745817fd340022e65c0c066d36 +config-catalog.zh.md: 6d961f22b3c29fbc4355e99c8de55823e014b74c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5dcec11452..af72b83272 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -262,7 +262,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:64`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cde0be16f3..6d961f22b3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -264,7 +264,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:64`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 7058fb3244..7415bf940f 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -28,9 +28,6 @@ export { canPassThroughNormalization, normalizeImage } from './normalization.ts' export type { NormalizedImage, NormalizationPolicy } from './normalization.ts' export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' export type { PreparedImageFile } from './store.ts' -export { - fileLeafName, readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, -} from './file-store.ts' export { readRequestImageFile, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 5f509ace06..998a71b2c3 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -33,5 +33,3 @@ export interface FileUploadService { onProgress?: (progress: FileUploadProgress) => void, ): Promise> } - -export type { ClientFileUploadHooks, FileUploadFetch } from '../types.ts' diff --git a/packages/client/file-upload/src/client/index.ts b/packages/client/file-upload/src/client/index.ts index a9b6bb3ef8..bf6696a423 100644 --- a/packages/client/file-upload/src/client/index.ts +++ b/packages/client/file-upload/src/client/index.ts @@ -4,13 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import { FileUploadRuntime } from './runtime.ts' import type { FileUploadService } from './contract.ts' -export type { - ClientFileUploadHooks, - FileUploadBody, - FileUploadFetch, - FileUploadProgress, - FileUploadService, -} from './contract.ts' +export type { FileUploadProgress, FileUploadService } from './contract.ts' export type { FileUploadReceiptId, FileUploadValue } from '../types.ts' declare module '@deepseek-ai/cordis' { diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index 38ab535a1f..a54e31121c 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -6,13 +6,10 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { FILE_UPLOAD_PATH } from '../protocol.ts' -import type { EncodedFileUploadRequest, FileUploadValue } from '../types.ts' import type { - ClientFileUploadHooks, - FileUploadBody, - FileUploadFetch, - FileUploadService, -} from './contract.ts' + ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, +} from '../types.ts' +import type { FileUploadBody, FileUploadService } from './contract.ts' interface FileUploadRequest { readonly path: string diff --git a/packages/client/file-upload/src/http-route.ts b/packages/client/file-upload/src/http-route.ts new file mode 100644 index 0000000000..0f5539324e --- /dev/null +++ b/packages/client/file-upload/src/http-route.ts @@ -0,0 +1,81 @@ +/** Authenticated raw-byte upload route registered on the Connection fetch registry. */ + +import { brandString } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { FileUploads } from './index.ts' +import type { FileUploadValue } from './types.ts' + +type FileUploadHttpResult = + | { readonly ok: true; readonly value: FileUploadValue } + | { + readonly ok: false + readonly error: { readonly code: string; readonly message: string; readonly details: object } + } + +/** + * Handle one authenticated raw-byte upload. + * @param service - Host upload service receiving streamed bytes. + * @param request - authenticated HTTP request from Connection. + * @returns JSON result using HTTP status 200 after request validation. + */ +export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise { + if (request.method !== 'POST') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }) + } + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/octet-stream') { + return new Response('content type must be application/octet-stream', { status: 415 }) + } + const url = new URL(request.url) + const sessionId = url.searchParams.get('sessionId') + if (sessionId === null || sessionId === '') { + return new Response('sessionId is required', { status: 400 }) + } + const name = url.searchParams.get('name') ?? undefined + let result: FileUploadHttpResult + try { + result = { + ok: true, + value: await service.uploadStream({ + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + } + } catch (error) { + const failure = remoteErrorOf(error) + result = { + ok: false, + error: failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: 'gateway/internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function* requestBodyChunks(body: ReadableStream | null): AsyncIterable { + if (body === null) return + const reader = body.getReader() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) return + yield chunk.value + } + } finally { + reader.releaseLock() + } +} diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 531ec3e464..2520ba1225 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -4,16 +4,15 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-client-connection' import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' import { scopeOf } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { handleFileUploadHttp } from './http-route.ts' import { FILE_UPLOAD_PATH } from './protocol.ts' import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' -export { FILE_UPLOAD_PATH } from './protocol.ts' export type * from './types.ts' declare module '@deepseek-ai/cordis' { @@ -38,13 +37,6 @@ export interface PromptFileBinding extends Disposable { commit(): void } -type FileUploadHttpResult = - | { readonly ok: true; readonly value: FileUploadValue } - | { - readonly ok: false - readonly error: { readonly code: string; readonly message: string; readonly details: object } - } - class PromptFileBindingGuard implements PromptFileBinding { private settled = false @@ -262,73 +254,6 @@ export class FileUploads extends TypertRemoteService { } } -/** - * Handle one authenticated raw-byte upload. - * @param service - Host upload service receiving streamed bytes. - * @param request - authenticated HTTP request from Connection. - * @returns JSON result using HTTP status 200 after request validation. - */ -export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise { - if (request.method !== 'POST') { - return new Response(null, { status: 405, headers: { allow: 'POST' } }) - } - const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - if (mediaType !== 'application/octet-stream') { - return new Response('content type must be application/octet-stream', { status: 415 }) - } - const url = new URL(request.url) - const sessionId = url.searchParams.get('sessionId') - if (sessionId === null || sessionId === '') { - return new Response('sessionId is required', { status: 400 }) - } - const name = url.searchParams.get('name') ?? undefined - let result: FileUploadHttpResult - try { - result = { - ok: true, - value: await service.uploadStream({ - sessionId: brandString(sessionId), - data: requestBodyChunks(request.body), - signal: request.signal, - ...(name === undefined ? {} : { name }), - }), - } - } catch (error) { - const failure = remoteErrorOf(error) - result = { - ok: false, - error: failure !== undefined - ? { code: failure.code, message: failure.message, details: failure.details } - : { - code: 'gateway/internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, - } - } - return new Response(JSON.stringify(result), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - }, - }) -} - -async function* requestBodyChunks(body: ReadableStream | null): AsyncIterable { - if (body === null) return - const reader = body.getReader() - try { - while (true) { - const chunk = await reader.read() - if (chunk.done) return - yield chunk.value - } - } finally { - reader.releaseLock() - } -} - function fileNotStaged(): RemoteError { return new RemoteError( 'session/attachment-invalid' as never, diff --git a/packages/api/session-controller/tests/file-upload-http.host.spec.ts b/packages/client/file-upload/tests/file-upload-http.host.spec.ts similarity index 97% rename from packages/api/session-controller/tests/file-upload-http.host.spec.ts rename to packages/client/file-upload/tests/file-upload-http.host.spec.ts index 60fd54fea9..cd137857ce 100644 --- a/packages/api/session-controller/tests/file-upload-http.host.spec.ts +++ b/packages/client/file-upload/tests/file-upload-http.host.spec.ts @@ -2,7 +2,8 @@ import { runInNewContext } from 'node:vm' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' -import { FileUploads, handleFileUploadHttp } from '@deepseek-ai/dsh-client-file-upload' +import { handleFileUploadHttp } from '../src/http-route.ts' +import type { FileUploads } from '../src/index.ts' function request(input: { method?: string diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 510993d94f..66b27b102a 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -3,7 +3,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply } from '../src/client/index.ts' import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' -import type { ClientFileUploadHooks, FileUploadBody } from '../src/client/contract.ts' +import type { FileUploadBody } from '../src/client/contract.ts' +import type { ClientFileUploadHooks } from '../src/types.ts' interface UploadGlobal { __DSH_FILE_UPLOAD__?: ClientFileUploadHooks diff --git a/packages/client/file-upload/tsconfig.host.json b/packages/client/file-upload/tsconfig.host.json index d0645185cf..6c8c29bc15 100644 --- a/packages/client/file-upload/tsconfig.host.json +++ b/packages/client/file-upload/tsconfig.host.json @@ -5,7 +5,7 @@ "outDir": "lib/types", "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, - "files": ["src/index.ts", "src/protocol.ts", "src/types.ts"], + "files": ["src/http-route.ts", "src/index.ts", "src/protocol.ts", "src/types.ts"], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../util/brand" }, From 75d3402b846d59a946ad1149ef0cd7a30f171dae Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 15:59:33 +0800 Subject: [PATCH 37/37] fix(web): keep skill chips on rebuilt message nodes --- apps/web/tests/skill-user-invoke.e2e.ts | 4 ++++ .../conversation-nodes/chat-snapshot-builder.ts | 7 ++++++- .../tests/skill-name-projector.client.spec.ts | 13 +++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index e9b6891b7b..5201f23a96 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -130,6 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await settled const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) await process.waitFor({ state: 'visible', timeout: 10_000 }) + // The chip derives from the step's logged injection, so it must survive + // every later Node rebuild of the Turn (process publication, turn close). + expect(await bubble.count()).toBe(1) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) await expandOwningTurnProcess(page, injectionFlow) const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) await injectionRow.click() diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 3495b565ca..4fac775354 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -617,7 +617,12 @@ export class SkillNameProjector { const next = slashEntryOf(node) const previous = this.entries.get(node.key) if (previous !== undefined) { - if (next !== null && sameSlashEntry(previous, next)) continue + if (next !== null && sameSlashEntry(previous, next)) { + // A rebuilt message Node carries Definition state only, never the + // names a previous apply attached: re-read its batch. + if (next.kind === 'message') dirty.push(next.seq) + continue + } this.remove(previous) dirty.push(previous.seq) } diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts index 0be8dca394..6e1f375adb 100644 --- a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -76,6 +76,19 @@ describe('SkillNameProjector', () => { expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo']) }) + it('re-decorates a direct message the assembler re-emits without names', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + expect(names(replaced[0])).toEqual(['demo']) + const store = storeOf(replaced) + // A rebuilt message Node carries Definition state only: no skillNames. + const rebuilt = user(2, '/demo go') + const out = projector.apply([rebuilt], store) + expect(out.map(node => node.key)).toEqual(['user:2']) + expect(names(out[0])).toEqual(['demo']) + expect(store.valuesCalls).toBe(0) + }) + it('a boundary arriving inside a batch splits it and drops the names past it', () => { const projector = new SkillNameProjector() const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')])