Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-apiproxy
The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
## Contract layer (`/api`)
@@ -10,6 +10,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Frontend Workspace and Session Intents are client-only and have no wire method.
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
@@ -24,6 +26,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-host-apiproxy",
"description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -41,12 +41,18 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"peerDependencies": {
@@ -54,6 +60,8 @@
"@deepseek-ai/dsh-invariants": "^0.0.1"
},
"devDependencies": {
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "workspace:^"
}

View File

@@ -0,0 +1,981 @@
/**
* Host-side ApiProxy implementation. Signature discipline: unary takes the
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AttachmentError } from '@deepseek-ai/dsh-attachment-local'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionSummary, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Surface message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
function decodeBase64(data: string): Uint8Array {
if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
const decoded = Buffer.from(data, 'base64')
if (decoded.toString('base64') !== data) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
return new Uint8Array(decoded)
}
async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
const limits = ctx.attachments.imageLimits
const prepared = content.map(part => part.type === 'text'
? part
: { part, data: decodeBase64(part.data) })
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
if (images.length > limits.maxImagesPerMessage) {
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
if (totalBytes > limits.maxMessageImageBytes) {
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
// Validate the complete batch before persisting any member: the store has no
// garbage collection, so one malformed image must not leave the batch's
// valid members as published objects no message event will ever reference.
for (const image of images) {
ctx.attachments.validateImage({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})
}
return Promise.all(prepared.map(async (item): Promise<ContentBlock> => {
if (!('data' in item)) return { type: 'text', text: item.text }
const attachment = await ctx.attachments.saveImage({
data: item.data,
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
return { type: 'image', attachment }
}))
}
function imageInContent(content: unknown, attachmentId: string): ImageAttachmentRef | undefined {
if (!Array.isArray(content)) return undefined
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
if (String(ref.attachmentId) === attachmentId) return ref
}
if (block.type === 'tool-result') {
const nested = imageInContent(block.content, attachmentId)
if (nested !== undefined) return nested
}
}
return undefined
}
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
for (const event of events) {
const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } }
const direct = imageInContent(data.content, attachmentId)
if (direct !== undefined) return direct
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
const streamed = imageInContent([data.chunk.block], attachmentId)
if (streamed !== undefined) return streamed
}
}
return undefined
}
/**
* Message-boundary pagination: count maxMessages surface messages backwards from
* the window tail; the cut is the starting seq of the oldest message group
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
* naturally includes the in-progress partial.
*/
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number,
): { events: SessionEvent[]; hasMore: boolean } {
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
let count = 0
let cut = 0
for (let i = window.length - 1; i >= 0; i--) {
const event = window[i] as SessionEvent
if (!MESSAGE_TYPES.has(event.type)) continue
count++
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
if (count >= maxMessages) {
cut = groupStart
break
}
}
const page = window.filter(event => event.seq >= cut)
return { events: page, hasMore: cut > 0 }
}
/** Wrap an ok result echoing the request's rpcId. */
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: true, value } }
}
/** Wrap an error result echoing the request's rpcId. */
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
class FrameQueue<F> {
private buffer: F[] = []
private waiter: (() => void) | undefined
private done = false
push(item: F): void {
if (this.done) return
this.buffer.push(item)
this.waiter?.()
}
end(): void {
this.done = true
this.waiter?.()
}
async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
const onAbort = (): void => { this.end() }
signal.addEventListener('abort', onAbort, { once: true })
try {
while (true) {
while (this.buffer.length > 0) yield this.buffer.shift() as F
if (this.done || signal.aborted) return
await new Promise<void>((resolve) => { this.waiter = resolve })
this.waiter = undefined
}
} finally {
signal.removeEventListener('abort', onAbort)
cleanup()
}
}
}
/**
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
* for answerable frames belong to the approval/question registry, absent in
* this minimal version).
*/
function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
running,
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}
}
/**
* SessionSummary projection for cold (persisted, unattached) sessions.
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
*/
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
}
return {
sessionId: meta.id,
updatedAt,
running: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
}
}
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
provider: string
model: string
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
}
/** The tool/call payload fields the presenter path reads. */
interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
sessionId: SessionId
questions: AskUserQuestionItem[]
resolve: (answer: AskUserQuestionAnswer) => void
reject: (error: UserInteractionError) => void
signal?: AbortSignal
onAbort?: () => void
}
/** Validate one answer batch against the exact question request it resolves. */
function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
if (payload.sessionId !== pending.sessionId) return false
const answers = payload.answer.answers
if (answers.length !== pending.questions.length) return false
return answers.every((answer, index) => {
const question = pending.questions[index] as AskUserQuestionItem
if (answer.id !== question.id) return false
if (new Set(answer.selected).size !== answer.selected.length) return false
const custom = answer.custom?.trim()
if (custom !== undefined && custom === '') return false
if (custom !== undefined && answer.selected.length > 0) return false
if (question.multiSelect !== true && answer.selected.length > 1) return false
const labels = new Set(question.options?.map(option => option.label) ?? [])
return answer.selected.every(label => labels.has(label))
})
}
/**
* Compute the render intent for a tool/call or tool/result event through the
* presenters registered at this moment; every other event type gets none. A
* result's presenter needs its call's parsed args — `argsFor` supplies them
* (live: the per-session call table; history: an in-page backscan), returning
* undefined when the pairing is unavailable (e.g. the call fell off the page),
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
* the client's documented default (generic JSON card) covers every miss.
*/
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
try {
if (event.type === 'tool/call') {
const { name, arguments: raw } = event.data as ToolCallData
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const { callId, content, isError, meta } = event.data as ToolResultData
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
return view === undefined ? undefined : { for: 'result', view }
}
} catch (error: unknown) {
// A throwing presenter (or unparseable arguments) must not break delivery;
// the event still ships, just without a view.
console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
}
return undefined
}
/**
* Resolve a tool/result's call pairing by scanning a window of events backwards
* for the matching tool/call. Used by the history path (the page is the
* window — a cross-page pairing soft-falls to no view) and by live-path table
* misses after a reconnect-eviction.
*/
function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i] as SessionEvent
if (event.type !== 'tool/call') continue
const data = event.data as ToolCallData
if (data.callId !== callId) continue
try {
return { name: data.name, args: JSON.parse(data.arguments) }
} catch {
// Unparseable stored arguments: same soft-fall as a live parse failure.
return undefined
}
}
return undefined
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
*/
class SessionNotFound extends Error {}
/** Requested identity already belongs to a session with another project cwd. */
class SessionCwdConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedCwd: string,
readonly existingCwd: string | undefined,
) {
super(
`session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
+ `requested ${JSON.stringify(requestedCwd)}`,
)
}
}
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** Wire projection of one workspace entity (the workspace.* value row). */
function workspaceView(workspace: Workspace): WorkspaceView {
return {
workspaceId: workspace.id,
path: workspace.path,
title: workspace.title,
sessionIds: [...workspace.sessionIds],
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
}
}
/** Wire projection of the durable record carried by `domain/changed`. */
function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
const record: WorkspaceRecord = workspaceRecord.parse(value)
return {
workspaceId: workspaceId as WorkspaceId,
path: record.path,
title: record.title,
sessionIds: [...record.sessionIds],
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
/**
* Implement ApiProxy over a composed host context.
* @param ctx - a context with the Host spine and Workspace registry mounted.
* @param defaults - host routing and project-directory defaults.
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership checks with record creation across spellings. */
let workspaceCreationChain = Promise.resolve()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
for (const queue of muxQueues) queue.push(envelope)
}
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
pendingQuestions.delete(pending.rpcId)
if (pending.signal !== undefined && pending.onAbort !== undefined) {
pending.signal.removeEventListener('abort', pending.onAbort)
}
broadcast({
type: 'question/resolved', sessionId: pending.sessionId,
questionRpcId: pending.rpcId, outcome,
})
}
const disposeProvider = ctx.userInteraction.registerProvider({
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const sessionId = request.agent?.id
if (sessionId === undefined) {
return Promise.reject(new UserInteractionError(
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const rpcId = RpcId(randomUUID())
const pending: PendingQuestion = {
rpcId, sessionId, questions: request.questions, resolve, reject,
...(request.signal === undefined ? {} : { signal: request.signal }),
}
const onAbort = (): void => {
claimQuestion(pending, 'cancelled')
reject(new UserInteractionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
}
pending.onAbort = onAbort
pendingQuestions.set(rpcId, pending)
request.signal?.addEventListener('abort', onAbort, { once: true })
const envelope: RpcRequest<MuxFrame> = {
rpcId,
payload: { type: 'question/requested', sessionId, questions: request.questions },
}
for (const queue of muxQueues) queue.push(envelope)
})
},
})
ctx.effect(() => () => {
disposeProvider()
for (const pending of [...pendingQuestions.values()]) {
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'web user-interaction provider was disposed', 'ASK_ABORTED'))
}
}, 'api-proxy: user-interaction provider')
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
* not-found before any resume is attempted. With the gate passed, a later
* resume failure is genuinely internal. No persistence configured skips the
* gate — resume itself then fails loud with its own diagnostic.
*/
async function assertServable(sessionId: SessionId): Promise<void> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) return
const meta = (await persistence.list()).find(m => m.id === sessionId)
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const live = ctx.agents.get(sessionId)
if (live !== undefined) return { agent: live }
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
await assertServable(sessionId)
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
return handle.agent
} finally {
resumes.delete(sessionId)
}
})()
resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
// The internal details slot is contractually {}; the reason rides the message.
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
}
}
/** Resolve one requested identity to a live agent, creating or resuming it once. */
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
let creation = sessionCreations.get(sessionId)
if (creation === undefined) {
creation = (async () => {
const live = ctx.agents.get(sessionId)
if (live !== undefined) return live
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
const stored = persistence === undefined
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (stored !== undefined) {
if (stored.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
}
return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
}
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
// this operation crossed an asynchronous persistence/filesystem step.
const live = ctx.agents.get(sessionId)
if (live !== undefined) return live
throw error
}).finally(() => {
sessionCreations.delete(sessionId)
})
sessionCreations.set(sessionId, creation)
}
const agent = await creation
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
return agent
}
/** Resolve or create one path while holding the Host's workspace-create chain. */
function ensureWorkspace(
path: string,
title: string | undefined,
rejectExistingName = false,
createDirectory = false,
): Promise<{ workspace: Workspace; created: boolean }> {
const operation = workspaceCreationChain.then(async () => {
if (rejectExistingName && title !== undefined
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
throw new WorkspaceNameConflictError(title)
}
if (createDirectory) {
try {
await mkdir(path, { recursive: true })
} catch (error: unknown) {
throw new WorkspaceDirectoryCreationError(
`failed to create workspace directory "${path}": ${String(error)}`,
)
}
}
const existing = await ctx.workspace.resolveByPath(path)
if (existing !== undefined) return { workspace: existing, created: false }
return { workspace: await ctx.workspace.create(path, title), created: true }
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
return operation
}
return {
sessions: {
// Attached sessions summarize from memory; persisted-but-unattached (cold)
// sessions merge in from the persistence store so history survives restarts.
// Legacy logs without a cwd (pre-project stance) are not served — every
// session now records its project at create time.
async list(request) {
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
return summarize(session, agent?.status === 'running')
})
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return ok(request, { items })
},
async create(request) {
const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
let workspace: Workspace | undefined
if (request.payload.workspaceId !== undefined) {
workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `workspace "${request.payload.workspaceId}" not found`,
details: { workspaceId: request.payload.workspaceId },
})
}
}
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
try {
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
} catch (error: unknown) {
if (error instanceof SessionCwdConflict) {
return err(request, {
code: 'session-conflict',
message: error.message,
details: {
sessionId: error.sessionId,
requestedCwd: error.requestedCwd,
...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
},
})
}
return err(request, {
code: 'internal',
message: `failed to create session "${sessionId}": ${String(error)}`,
details: {},
})
}
if (workspace !== undefined) {
try {
await workspace.attachSession(sessionId)
} catch (error: unknown) {
return err(request, {
code: 'workspace-attach-failed',
message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
details: { sessionId, workspaceId: workspace.id },
})
}
}
return ok(request, { sessionId })
},
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
// a call and its result on one page — a cross-page miss soft-falls).
const entries: HistoryEntry[] = page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (content.some(part => part.type === 'image')) {
const routed = agent.session.requestHeader()?.config
const provider = routed?.provider ?? agent.options.provider ?? defaults.provider
const model = routed?.model ?? agent.options.model ?? defaults.model
const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model)
if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
return err(request, {
code: 'attachment-error',
message: `Model "${model}" does not support image input.`,
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
})
}
}
const durable = await durablePromptContent(ctx, content)
if (mode === 'steer') agent.steer(durable, { source })
else agent.followup(durable, { source })
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
}
return ok(request, { accepted: true as const })
},
async attachment(request) {
const { sessionId, attachmentId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const ref = referencedImage(found.agent.session.events, String(attachmentId))
if (ref === undefined) {
return err(request, {
code: 'attachment-error',
message: 'Image is not referenced by this session.',
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
})
}
try {
const stored = await ctx.attachments.readImage(ref)
return ok(request, {
attachment: stored.ref,
data: Buffer.from(stored.data).toString('base64'),
})
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
return err(request, {
code: 'internal',
message: 'Unable to read image attachment.',
details: {},
})
}
},
cancel(request) {
const { sessionId } = request.payload
const agent = ctx.agents.get(sessionId)
if (agent === undefined) {
return Promise.resolve(err(request, {
code: 'session-not-found',
message: `session "${sessionId}" not found (not attached)`,
details: { sessionId },
}))
}
agent.cancel()
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {
list(request) {
return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
},
// Exactly one of path/name arrives (schema refine). Existing-folder
// adoption reuses its canonical path; create-by-name rejects a name
// already present in the registry.
async create(request) {
const { payload } = request
let path: string
if (payload.name !== undefined) {
const name = payload.name.trim()
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
return err(request, {
code: 'workspace-invalid-path',
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
details: { path: payload.name },
})
}
path = join(defaults.workspaceRoot, name)
} else {
path = payload.path as string
}
try {
const name = payload.name?.trim()
const { workspace, created } = await ensureWorkspace(
path,
name,
name !== undefined,
name !== undefined,
)
return ok(request, { workspace: workspaceView(workspace), created })
} catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
if (error instanceof WorkspaceDirectoryCreationError) {
return err(request, { code: 'internal', message: error.message, details: {} })
}
// The registry rejects a path that does not resolve to an existing
// directory (realpath ENOENT / not-a-directory) — the business
// error of the typed-path flow, surfaced as a validation failure.
return err(request, {
code: 'workspace-invalid-path',
message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
details: { path },
})
}
},
},
host: {
async describe(request) {
const activeModel = (await ctx.llm.listModels(defaults.provider))
.find(model => model.id === defaults.model)
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
return ok(request, {
version: '0.0.1',
// Same source as session.create's fallback: the UI's default project
// must match where an unspecified-cwd session actually lands.
cwd: defaults.cwd,
provider: defaults.provider,
model: defaults.model,
...activeModel === undefined ? {} : { activeModel },
imageLimits: {
...ctx.attachments.imageLimits,
mediaTypes: [...ctx.attachments.imageLimits.mediaTypes],
},
attachedSessions: ctx.agents.list().length,
})
},
},
events: {
mux(_request, signal) {
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
muxQueues.add(queue)
for (const session of ctx.sessions.list()) {
subscribeSession(queue, session)
}
for (const pending of pendingQuestions.values()) {
queue.push({
rpcId: pending.rpcId,
payload: {
type: 'question/requested', sessionId: pending.sessionId,
questions: pending.questions,
},
})
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
// opened mid-turn) backscans the session's in-memory events instead.
const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
const disposers = [
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type === 'tool/call') {
const data = event.data as ToolCallData
try {
let table = openCalls.get(session.id)
if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
} catch {
// Unparseable model arguments: leave the table unset; the result view soft-falls.
}
} else if (event.type === 'turn/end') {
openCalls.delete(session.id)
}
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
]
return queue.iterate(signal, () => {
muxQueues.delete(queue)
for (const dispose of disposers) dispose()
})
},
host(_request, signal) {
const queue = new FrameQueue<RpcRequest<HostFrame>>()
const committedWorkspaceIds = new Set(
ctx.workspace.list().map(workspace => String(workspace.id)),
)
const disposers = [
ctx.on('session/created', (session: Session) => {
queue.push(frame({
type: 'host/session-added',
sessionId: session.id,
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
// cwd rides the frame so the client list needs no refresh to group the new session.
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}))
}),
ctx.on('session/disposed', (session: Session) => {
queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
}),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
if (status === 'disposed') return
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
}),
ctx.on('domain/changed', (change) => {
if (change.domain !== 'workspace' || change.operation !== 'put') return
if (change.table === '') {
const state = workspaceDomainState.parse(change.value)
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
const workspace = ctx.workspace.get(workspaceId)
if (workspace === undefined) {
throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
}
committedWorkspaceIds.add(workspaceId)
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
}
return
}
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
// Existing-entity table writes are complete attach/touch commits.
// A new entity's first put waits for the global registry write above.
queue.push(frame({
type: 'host/workspace-changed',
workspace: changedWorkspaceView(change.key, change.value),
}))
}),
]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {
if (message.result.error.code !== 'cancelled') {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
return Promise.resolve({ accepted: true })
}
const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
if (!parsed.success) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
const payload: QuestionResponsePayload = {
sessionId: parsed.data.sessionId,
answer: {
answers: parsed.data.answer.answers.map(answer => ({
id: answer.id,
selected: answer.selected,
...(answer.custom === undefined ? {} : { custom: answer.custom }),
})),
},
}
if (!matchesQuestions(payload, pending)) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'answered')
pending.resolve(payload.answer)
return Promise.resolve({ accepted: true })
},
}
}

View File

@@ -11,6 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
import { workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
export const askUserQuestionItemSchema = z.object({
@@ -39,9 +40,10 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
/** HostFrame union (payload slot of a host-stream ServerRequest). */
export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }),
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>

View File

@@ -12,6 +12,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
// dsh-tools remains its owner.
@@ -62,10 +63,18 @@ export type MuxFrame =
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
| { type: 'stream/error'; error: RpcError }
/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */
/**
* Host stream frames. session-added carries the lineage anchor and the
* project cwd (the list-summary fields a client cannot wait for a refresh to
* learn); agent-error is the only outlet for live failures with no turn
* position; workspace-changed pushes the full new snapshot after every
* durable workspace mutation (create/attach/order change — the client
* upserts, while `workspace.list` provides the reconnect baseline).
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId }
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId; cwd?: string }
| { type: 'host/session-removed'; sessionId: SessionId }
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'stream/error'; error: RpcError }

View File

@@ -6,6 +6,7 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { EventsApi } from './events.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
@@ -13,6 +14,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
export interface ApiProxy {
sessions: SessionsApi
host: HostApi
workspace: WorkspaceApi
events: EventsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
@@ -21,6 +23,7 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, PromptContentPart, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -6,6 +6,7 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { RpcResponse } from './rpc.ts'
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
@@ -17,6 +18,8 @@ export interface RpcMethodMap {
'session.attachment': SessionsApi['attachment']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -35,6 +35,11 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),

View File

@@ -32,6 +32,11 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
'workspace-not-found': { workspaceId: string }
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'agent-busy': { reason: string }
'attachment-error': { reason: string }
'internal': {}

View File

@@ -12,10 +12,19 @@ import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { WorkspaceId } from './workspace.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
/**
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
* than in workspace.schema because session.create references it while
* workspace.schema references sessionIdSchema — schema modules must stay a
* DAG (both casts used at module top level; a cycle is a load-time TDZ).
*/
export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType<WorkspaceId>
/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */
export const sessionEventSchema = z.object({
type: z.string(),
@@ -45,10 +54,15 @@ export const sessionListValueSchema = z.object({
items: z.array(sessionSummarySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
/** session.create request payload. */
/** session.create request payload (at most one of workspaceId / cwd). */
export const sessionCreateRequestSchema = z.object({
workspaceId: workspaceIdSchema.optional(),
cwd: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
sessionId: sessionIdSchema.optional(),
}).refine(
payload => payload.workspaceId === undefined || payload.cwd === undefined,
{ message: 'session.create accepts workspaceId or cwd, not both' },
) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
/** session.create response value. */
export const sessionCreateValueSchema = z.object({

View File

@@ -8,6 +8,7 @@ import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deep
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
@@ -54,8 +55,16 @@ export interface SessionsApi {
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
/** Creates a new session (and its agent, idle and standing by). */
create(request: RpcRequest<{ cwd?: string }>): Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Creates a real session and its idle agent. At most one of `workspaceId` /
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
* preallocate `sessionId`: retries with the same id and cwd return the same
* session, while a different cwd fails with `session-conflict`.
* Workspace creation attaches the session after publication; an attach
* failure returns `workspace-attach-failed` with the published session id.
*/
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Reads a window of history events; page boundaries align to message boundaries: one page =

View File

@@ -0,0 +1,46 @@
/**
* workspace domain zod schemas (names derived from map keys). The
* WorkspaceId brand cast lives in sessions.schema (see the note there) and
* is re-exported here as the domain-local name.
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { WorkspaceView } from './workspace.ts'
import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts'
export { workspaceIdSchema } from './sessions.schema.ts'
/** WorkspaceView row of every workspace.* response. */
export const workspaceViewSchema = z.object({
workspaceId: workspaceIdSchema,
path: z.string(),
title: z.string(),
sessionIds: z.array(sessionIdSchema),
createdAt: z.string(),
updatedAt: z.string(),
}) satisfies z.ZodType<Wire<WorkspaceView>>
/** workspace.list request payload (empty object literal). */
export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'workspace.list'>>>
/** workspace.list response value. */
export const workspaceListValueSchema = z.object({
items: z.array(workspaceViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
export const workspaceCreateRequestSchema = z.object({
path: z.string().optional(),
name: z.string().optional(),
}).refine(
payload => (payload.path === undefined) !== (payload.name === undefined),
{ message: 'workspace.create requires exactly one of path / name' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
/** workspace.create response value. */
export const workspaceCreateValueSchema = z.object({
workspace: workspaceViewSchema,
created: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>

View File

@@ -0,0 +1,55 @@
/**
* workspace domain contract. Wire projection of the host-side workspace
* entity (@deepseek-ai/dsh-workspace): a stable id over a directory path,
* a display title, and the ordered session account. Method signatures are the
* source of truth, same as the sessions domain.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { RpcRequest, RpcResponse } from './rpc.ts'
/**
* Wire-side workspace id brand. Deliberately re-declared here rather than
* imported from dsh-workspace: api/ must stay browser-importable with zero
* host-package dependencies, and the brand string matches, so both sides
* agree structurally.
*/
export type WorkspaceId = Branded<'WorkspaceId'>
/** One workspace row: the record projection every workspace.* value carries. */
export interface WorkspaceView {
workspaceId: WorkspaceId
/** Canonical directory path (host-side realpath canon). */
path: string
/** Unique display title (defaults to the path basename at create). */
title: string
/** Sessions accounted under this workspace, newest-first for display. */
sessionIds: SessionId[]
/** ISO-8601 creation instant. */
createdAt: string
/** ISO-8601 last-mutation instant. */
updatedAt: string
}
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
export interface WorkspaceApi {
/** Lists all workspaces in the registry's durable display order. */
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[] }>>
/**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
* `name` (schema-enforced): `path` registers an EXISTING directory (no
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
* `name` is a single path segment the host mkdirs under its default project
* root before registering. Either spelling resolving to a directory already
* owned by a workspace returns that workspace (`created: false`) for the
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; a new path whose basename duplicates another
* Workspace title is rejected by the registry with the same code.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
*/
create(request: RpcRequest<{ path?: string; name?: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
}

View File

@@ -22,6 +22,10 @@ import {
sessionListValueSchema,
sessionPromptValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
workspaceListValueSchema,
} from '../api/workspace.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -50,6 +54,10 @@ export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
@@ -70,6 +78,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.attachment': sessionAttachmentValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -257,6 +267,11 @@ export abstract class AbstractApiClient implements IApiClient {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
}
readonly events: IApiClient['events'] = {
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),

View File

@@ -23,6 +23,10 @@ import {
sessionPromptRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceListRequestSchema,
} from '../api/workspace.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -46,6 +50,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */

View File

@@ -1,13 +1,81 @@
/**
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
* lives in @deepseek-ai/dsh-host-runtime.
* @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
* carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side), and the host-side implementation
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
*/
import { resolve } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
interface Context {
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
apiProxy: ApiProxy
}
}
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
export interface Config {
/** Default provider route for created/resumed agents. */
provider: string
/** Default model id. */
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
}
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'attachments', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
workspaceRoot: z.string(),
})
readonly sessions: ApiProxy['sessions']
readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host']
readonly events: ApiProxy['events']
readonly respond: ApiProxy['respond']
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const cwd = process.cwd()
const api = createApiProxy(ctx, {
provider: config.provider,
model: config.model,
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
this.sessions = api.sessions
this.workspace = api.workspace
this.host = api.host
this.events = api.events
// createApiProxy returns closures (no `this` capture); bind only satisfies
// the unbound-method lint without changing behavior.
this.respond = api.respond.bind(api)
}
}
export default ApiProxyService

View File

@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this package is the wire contract layer (types,
* schemas, fetch carrier glue) — it emits no cordis events and owns no
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
* enforced at the carrier boundary and exercised by the protocol-isomorphism
* suite; the live implementation relations belong to dsh-host-runtime.
* No runtime invariant: this package is the wire contract layer plus the
* host-side gateway over services owned elsewhere — it emits no cordis events
* of its own; the session/agent event streams it projects are asserted by
* their owning packages' companions. rpcId round-trip and schema acceptance
* are enforced at the carrier boundary and exercised by the
* protocol-isomorphism suite.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,97 @@
/**
* Cold-session and degenerate-composition paths of the host ApiProxy:
* sessions.list merging persisted-but-unattached summaries (mtime source,
* createdAt fallbacks, lineage projection) and the resume error split when
* the composition has no persistence gate and no agent factory.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
}
describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent') }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
// locate: a real per-session file (mtime wins), a backend without one
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
// → createdAt).
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return undefined
},
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const items = response.result.value.items
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
const [a, b, c] = items
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
expect(a?.running).toBe(false)
expect(a?.cwd).toBe('/proj')
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(c?.updatedAt).toBe(1500)
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
// No persistence → the servable gate passes silently; the factory-less
// registry then rejects resume, which is NOT a SessionNotFound.
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
}
})
})

View File

@@ -0,0 +1,197 @@
/**
* Tool-card view computation over the mux live path: three standard card types
* arrive on the frame, a presenterless tool ships no view field, a call-only
* presenter keeps raw result content out of the view payload, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing
* works both through the live open-call table and the backscan fallback after
* turn/end cleared it.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
return defineContentToolFixture({
name,
description: `tool ${name}`,
parameters: {},
execute: () => reply(`ran:${name}`),
...presenters,
})
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
}))
ctx.tools.register(tool('term', {
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
presentResult: () => ({ card: 'terminal', output: 'done' }),
}))
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('call-only', {
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
}))
return { ctx }
}
/** Drain frames from an open mux stream until `count` session/event frames arrived. */
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
const frames: MuxFrame[] = []
for await (const frame of iterable) {
frames.push(frame.payload)
if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
}
return frames
}
describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 9, abort)
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-call-only'), content: [{ type: 'text', text: rawResult }], isError: false }, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'session/event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
for: 'call',
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
})
const callOnlyResult = byCall.get('tool/result:c-call-only')
expect('view' in (callOnlyResult ?? {})).toBe(false)
const serializedResult = JSON.stringify(callOnlyResult)
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.
expect(byCall.get('tool/call:c-boom')).toBeDefined()
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
// Result pairing through the live table: presentResult saw the call's args.
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
})
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-term'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { n: 1 } }, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-orphan'), content: [{ type: 'text', text: 'x' }], isError: false }, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-bad'), content: [{ type: 'text', text: 'y' }], isError: false }, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-plain'), content: [{ type: 'text', text: 'z' }], isError: false }, { surfaceOp: 'append' })
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const entries = response.result.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
let session: Session | undefined
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('session-doomed' as SessionId)
}, { inject: ['sessions'] }))
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
// Disposing the owning fiber detaches the session mid-stream; the
// session/disposed listener must clear its open-call table entry.
await fiber.dispose()
const frames = await collect(stream, 2, abort)
const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
expect(call?.type === 'session/event' && call.view?.for).toBe('call')
})
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The turn/end above cleared the live table; pairing must fall back to
// scanning the session's in-memory events.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
})
})

View File

@@ -0,0 +1,246 @@
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
}
function expectOk<T>(response: RpcResponse<T>): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
async function nextHostFrame(
stream: AsyncIterator<RpcRequest<HostFrame>>,
): Promise<RpcRequest<HostFrame>> {
const next = await stream.next()
if (next.done === true) throw new Error('Host stream ended before the expected increment')
return next.value
}
function stubAgent(session: Session): Agent {
return {
id: session.id,
options: {},
session,
status: 'idle',
ctx: new Context(),
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', storageDomain)
ctx.provide('storageDomain', storageDomain)
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
await ctx.plugin(WorkspaceRegistry)
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
const session = ctx.sessions.create(
options.sessionId,
options.meta === undefined ? {} : { meta: options.meta },
)
const agent = stubAgent(session)
const unregister = ctx.agents.register(agent)
return {
agent,
dispose: () => {
unregister()
return Promise.resolve()
},
}
},
async resume() {
throw new Error('test harness has no persisted sessions')
},
}
ctx.agents.setFactory(factory)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
})
return { api, ctx, storageDomain, workspaceRoot }
}
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()
const responses = await Promise.all([
api.workspace.create(request({ name: 'alpha' })),
api.workspace.create(request({ name: 'alpha' })),
])
const created = responses.find(response => response.result.ok)
const duplicate = responses.find(response => !response.result.ok)
expect(created).toBeDefined()
expect(expectOk(created!)).toMatchObject({
created: true,
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
})
expect(duplicate?.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
})
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
})
it('adopts only existing directories and rejects unsafe names', async () => {
const { api, workspaceRoot } = await harness()
const existing = join(workspaceRoot, 'existing')
mkdirSync(existing)
const first = expectOk(await api.workspace.create(request({ path: existing })))
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
const missing = join(workspaceRoot, 'missing')
const missingResult = await api.workspace.create(request({ path: missing }))
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
expect(existsSync(missing)).toBe(false)
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
const invalid = await api.workspace.create(request({ name }))
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
}
})
})
describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const sessionId = SessionId('session-workspace-preallocated')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
const ungrouped = SessionId('session-cwd-only')
expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
})
const missing = await api.sessions.create(request({
workspaceId: 'missing-workspace' as WorkspaceId,
sessionId: SessionId('session-missing-workspace'),
}))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
})
it('retains a published session when attachment fails and repairs it on retry', async () => {
const { api, ctx } = await harness()
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const workspace = ctx.workspace.list()[0]
if (workspace === undefined) throw new Error('workspace missing from registry')
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
const sessionId = SessionId('session-attach-retry')
const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
expect(failed.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
})
expect(ctx.agents.get(sessionId)).toBeDefined()
expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
})
})
describe('Host Workspace increments', () => {
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const workspaceIncrement = nextHostFrame(stream)
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
expect(await workspaceIncrement).toMatchObject({
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
})
const sessionId = SessionId('session-streamed-workspace')
const pending = nextHostFrame(stream)
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
const increments: HostFrame[] = []
increments.push((await pending).payload)
while (increments.length < 2) {
const next = await stream.next()
if (next.done === true) throw new Error('Host stream ended before both increments')
increments.push(next.value.payload)
}
expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
type: 'host/session-added', sessionId, cwd: workspace.path,
})
const workspaceChanged = increments.find(
(increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
increment.type === 'host/workspace-changed',
)
expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
abort.abort()
})
it('does not publish a Workspace whose registry-order commit fails', async () => {
const { api, storageDomain } = await harness()
const domain = storageDomain.get('workspace')
if (domain === undefined) throw new Error('workspace domain is not open')
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const next = stream.next()
const failed = await api.workspace.create(request({ name: 'ghost' }))
expect(failed.result.ok).toBe(false)
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
abort.abort()
expect(await next).toMatchObject({ done: true })
})
})

View File

@@ -38,6 +38,10 @@ function scriptedApi(overrides: {
...overrides.sessions,
},
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
workspace: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
}
@@ -194,6 +198,23 @@ describe('unary round trip', () => {
})
})
describe('workspace domain round trip', () => {
it('routes both workspace methods through their handler rows and value schemas', async () => {
const c = client(scriptedApi())
const list = await c.workspace.list({})
expect(list.result).toEqual({ ok: true, value: { items: [] } })
const created = await c.workspace.create({ path: '/t' })
expect(created.result.ok).toBe(true)
if (created.result.ok) expect(created.result.value.created).toBe(true)
})
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
const response = await client(scriptedApi()).workspace.create({})
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
})
})
describe('SSE stream path', () => {
it('yields frames in order and skips the comment preamble', async () => {
const frames: MuxFrame[] = [

View File

@@ -48,6 +48,17 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
},
},
workspace: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
},
async create(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
}
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),
host: (_request, signal) => stream(hostFrames, signal),

View File

@@ -13,6 +13,10 @@ import {
sessionPromptValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema,
workspaceListValueSchema, workspaceViewSchema,
} from '../src/api/workspace.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
@@ -32,6 +36,11 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'attachment-error', message: 'm', details: { reason: 'r' } }).code).toBe('attachment-error')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
@@ -98,6 +107,9 @@ describe('sessions domain schemas', () => {
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
@@ -134,6 +146,31 @@ describe('host domain schemas', () => {
})
})
describe('workspace domain schemas', () => {
const view = {
workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'],
createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
}
it('validates ids, the view row, and list request/value', () => {
expect(workspaceIdSchema.parse('w1')).toBe('w1')
expect(() => workspaceIdSchema.parse('')).toThrow()
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
expect(workspaceListRequestSchema.parse({})).toEqual({})
expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
})
it('create requires exactly one of path/name (both refine arms)', () => {
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
})
})
describe('events frame schemas', () => {
it('accepts every mux frame branch', () => {
const frames = [

View File

@@ -8,27 +8,48 @@
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../attachment/attachment"
},
{
"path": "../../attachment/attachment-local"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../workspace/workspace"
},
{
"path": "../../support/invariants"
}