From 8a0ff3aff82ab0ee5b2b9ef87b75abcd262fa776 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:38:21 +0800 Subject: [PATCH] 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",