feat(session-controller): admit files across command flows

This commit is contained in:
creatixchu
2026-09-01 16:11:43 +08:00
parent 65d2015b09
commit 8a0ff3aff8
12 changed files with 496 additions and 44 deletions
@@ -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:^",
+218 -8
View File
@@ -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<SessionId, Map<FileUploadReceiptId, StagedFileUpload>>()
/**
* @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<SessionUploadFileValue> {
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<Uint8Array>
readonly signal?: AbortSignal
readonly name?: string
}): Promise<SessionUploadFileValue> {
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<FileAttachmentRef>,
): Promise<SessionUploadFileValue> {
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<FileUploadReceiptId, FileAttachmentRef>()
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<SessionPromptRequest['content'][number], { readonly type: 'file' }>
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,
@@ -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<Response>
}): () => Promise<void>
}
}
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<Response> {
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<Uint8Array> | null,
): AsyncIterable<Uint8Array> {
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()
}
}
@@ -0,0 +1,2 @@
/** Authenticated raw-byte upload route shared by Host and Client faces. */
export const SESSION_FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
@@ -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<SessionUploadFileValue> {
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.
+26 -2
View File
@@ -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<SessionProjectionMap>
& Readonly<Record<string, SessionProjectionValue>>
/** 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. */
@@ -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",
+5 -5
View File
@@ -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 <objective> or /goal edit <objective>.',
text: 'Attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
}
}
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: '[<objective>|clear|edit <objective>|pause|resume]', images: true },
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
handler: invocation => executeGoalCommand(ctx, invocation),
})
}
+91 -24
View File
@@ -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<CommandExecution | undefined> {
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<typeof admitEncodedImages>[0],
attachments: readonly CommandSubmitAttachment[],
resolveFileReceipt: (receiptId: string) => FileAttachmentRef | undefined,
): Promise<readonly (ImageBlock | FileBlock)[]> {
const files = new Map<string, FileAttachmentRef>()
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<ImageBlock | FileBlock> = []
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
+9 -3
View File
@@ -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. */
+2 -2
View File
@@ -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)) {
+1
View File
@@ -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",