Merge origin/master into worktree/web-plugin-config

Three seams: the tsconfig path map gained a mapping on each side and keeps
both; the event-producer matrix is generated, so it was regenerated rather
than hand-merged row by row.
This commit is contained in:
Yichen Jiang
2026-08-11 18:27:53 +08:00
1460 changed files with 20030 additions and 19474 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 02559df3666397732f6c4009040c78722be29076
README.zh.md: 5d348d6b97f048a05aaf8a32fffb22d3a281049d
README.md: eb42aa3f16a4991aa5b5de22b3015491b5b15754
README.zh.md: fca7644a6beb94f7dd2e13bb95c2bfcd45d5df20

View File

@@ -28,6 +28,8 @@ Question responses are validated against their pending request before the first
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`.
@@ -36,6 +38,8 @@ Session model selection is a session-domain contract. `session.models` returns t
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.

View File

@@ -28,6 +28,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话日志导出是宿主侧的下载面,不是 RPC`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP其中每个文件都是会话存储工件的逐字原文持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成响应边生成边分块写出宿主从不把整个归档放进单个缓冲区且每当响应队列填满时生产会让出慢消费者因此只产生有界的积压fflate 的回调是同步的——让出点是唯一的背压手段。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500根会话缺失应答 404后代缺少存储工件或引用的图片无法读取则整个流失败fail-loud绝不静默少导出。端点由传输层挂载`ApiProxy.downloads.sessionLog` 实现它。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`
@@ -36,6 +38,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering中途引导不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement而注入上下文审批通知、任务完成、附加快照携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found``session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。

View File

@@ -65,11 +65,13 @@
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"fflate": "^0.8.2",
"zod": "^4.4.3"
},
"peerDependencies": {

View File

@@ -39,9 +39,16 @@ import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
sessionLogExportDeps,
sessionLogZipFilename,
streamSessionLogZip,
type SessionLogExportReady,
} from './session-export.ts'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
@@ -49,6 +56,9 @@ import {
} from './api/session-search.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('tasks')` to the background task registry.
import type {} from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
import type {} from '@deepseek-ai/dsh-session-projection-cache'
// GoalError narrows domain rejections to their stable codes at the wire boundary.
@@ -417,6 +427,22 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
}
/**
* Project registry snapshots onto the wire view, dropping the three internal
* fields {@link TaskView} documents as absent.
*/
function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
return snapshots.map(task => ({
id: task.id,
kind: task.kind,
label: task.label,
status: task.status,
...task.detail === undefined ? {} : { detail: task.detail },
startedAt: task.startedAt,
...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
}))
}
/**
* Whether the session's conversation has started: no turn has run yet (a
* turn is one model-loop execution). Standalone plugin events — command
@@ -697,6 +723,16 @@ function historyPage(
* registry). An absent registry means the deployment has no projection seam:
* the whole block is absent and clients treat every key as capability-absent.
*/
/**
* Which session a transcript read is served from. An attached session is the
* live object and keeps appending, so its events and projection baseline are
* read together in one synchronous step; a detached one is already a frozen
* inspection.
*/
type HistorySource =
| { readonly kind: 'attached'; readonly session: Session }
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] }
function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
@@ -1340,24 +1376,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return undefined
}
/** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
/**
* Resolve which session one transcript read is served from, without
* acquiring an Agent owner. This is the read's only asynchronous step
* besides ensuring the composition; {@link historyCutOf} takes the cut.
* @param sessionId - the transcript being read.
* @returns the attached session, or the inspected detached header and events.
* @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity.
*/
async function historySourceFor(sessionId: SessionId): Promise<HistorySource> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
}
if (attached !== undefined) return { kind: 'attached', session: attached }
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
...projections === undefined ? {} : { projections },
return { kind: 'detached', header: inspected.meta, events: inspected.events }
}
/**
* The header and events {@link presenterScopeFor} reads to decide which
* composition a transcript ran under.
* @param source - the live or detached session this read is served from.
* @returns that session's creation header and its events.
*/
function sourceSession(source: HistorySource): PresetBearingSession {
if (source.kind === 'detached') return { header: source.header, events: source.events }
return { header: source.session.header, events: source.session.events }
}
/**
* One transcript cut: the events and the projection baseline that describe
* the SAME log position.
*
* Synchronous, and the two reads sit next to each other, because an attached
* session keeps appending: an `await` between them would serve events cut at
* N beside a baseline folded to N+1, which is one response describing two
* moments. The caller does its awaiting before this call.
* @param source - the live or detached session this read is served from.
* @param includeProjections - whether the caller asked for the baseline (a tail page does).
* @returns the events and, when asked, the baseline for that same position.
*/
function historyCutOf(
source: HistorySource,
includeProjections: boolean,
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
if (source.kind === 'detached') {
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
return { events: source.events, ...projections === undefined ? {} : { projections } }
}
const events = [...source.session.events]
const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined
return { events, ...projections === undefined ? {} : { projections } }
}
/**
@@ -2017,9 +2084,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
const source = await historySourceFor(sessionId)
// Both awaits happen BEFORE the cut. Ensuring the recorded
// composition's standing mount is what registers its projection
// units, so a first cold read would otherwise serve a baseline
// missing every preset-owned key; and an attached session keeps
// appending, so awaiting between the two reads would pair events cut
// at N with a baseline folded to N+1.
const scope = await presenterScopeFor(sessionId, sourceSession(source))
const cut = historyCutOf(source, beforeSeq === undefined)
const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope)
return ok(request, {
events: page.events,
hasMore: page.hasMore,
...cut.projections === undefined ? {} : { projections: cut.projections },
})
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
@@ -2030,12 +2110,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
return ok(request, {
events: page.events,
hasMore: page.hasMore,
...state.projections === undefined ? {} : { projections: state.projections },
})
},
async models(request) {
@@ -3223,6 +3297,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
}
}
// Background-task baseline. `ctx.agents.get` is the non-resuming read:
// a session with no live Agent owns no tasks, so it correctly sees only
// the unowned ones, and listing never revives a cold session. An empty
// set sends nothing — absence is how the client reads "no tasks".
const tasks = ctx.get('tasks')
if (tasks !== undefined) {
for (const session of ctx.sessions.list()) {
const views = taskViews(tasks.list(ctx.agents.get(session.id)))
if (views.length > 0) {
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
}
}
}
// 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.
@@ -3250,10 +3337,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)
// The subscribe frame clears the client's task mirror, and a
// session born after the stream opened missed the baseline loop.
// Unowned tasks are visible to it from birth, so without this it
// would show none until the next registry change.
const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id)))
if (views.length > 0) {
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
}
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
if (owner !== undefined) {
// The exact owner instance the fence compares against, so the
// push stays correct even while that Agent's scope is tearing
// down and a lookup by id would already miss.
queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
return
}
// An unowned task is visible to every caller, so every subscribed
// session's set changed with it.
for (const session of ctx.sessions.list()) {
queue.push(frame({
type: 'session/tasks',
sessionId: session.id,
tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
}))
}
})],
]
return queue.iterate(signal, () => {
muxQueues.delete(queue)
@@ -3375,6 +3488,46 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
downloads: {
async sessionLog(request, signal) {
// Clean error path first: missing services answer 500 and a missing
// root artifact 404 before any zip byte is produced. The root content
// read here is reused as the first zip entry, so nothing is read twice.
const deps = sessionLogExportDeps(ctx)
if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
return new Response(
'session log export is unavailable: missing session-query, session-persistence, or attachments service',
{ status: 500 },
)
}
const ready: SessionLogExportReady = {
sessionQuery: deps.sessionQuery,
sessionPersistence: deps.sessionPersistence,
attachments: deps.attachments,
}
let root: SessionRawArtifact | undefined
try {
root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
} catch {
// Backend read failure: answer 500 without echoing the error, which
// may carry absolute host paths into the browser error bar.
return new Response('session log export failed to read the stored artifact', { status: 500 })
}
if (root === undefined) {
return new Response('session not found', { status: 404 })
}
return new Response(
streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal),
{
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
},
},
)
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Route by the echoed rpcId (the wire correlation): approvals first,
// then questions — the two registries share one id space of UUIDs.

View File

@@ -0,0 +1,26 @@
/**
* downloads domain zod schemas. The GET download surface has no wire
* envelope: the request arrives as query parameters (all strings), so its
* request schema parses the raw query-parameter object into the method's
* exact request shape. SessionId brand cast point: sessionIdSchema, and only
* there (hosted in sessions.schema like every other cast).
*/
import { z } from 'zod'
import type { DownloadsApi } from './downloads.ts'
import { sessionIdSchema } from './sessions.schema.ts'
/**
* session.export query params → the sessionLog request. `includeDescendants`
* accepts exactly `true`/`false`/absent; any other value is rejected (400) so
* a misspelled flag cannot silently under-export.
*/
export const sessionLogQuerySchema = z
.object({
sessionId: sessionIdSchema,
includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(),
})
.transform(query => ({
sessionId: query.sessionId,
...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}),
})) satisfies z.ZodType<Parameters<DownloadsApi['sessionLog']>[0]>

View File

@@ -0,0 +1,25 @@
/**
* downloads domain contract: host-only download surfaces — the GET-download
* channel family, the mirror of the SSE-stream `events` domain. No wire
* envelope: the carrier's GET routes answer these directly, and the browser
* `IApiClient` never exposes them.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Host-only download surfaces (no wire envelope; absent from IApiClient). */
export interface DownloadsApi {
/**
* Stream one session-log ZIP — the root artifact verbatim plus each subagent
* descendant's — as an attachment response. The carrier's GET route answers
* this directly; the browser never calls it.
* @param request - the root session id and whether to include descendants.
* @param signal - cancellation for the underlying reads.
* @returns the ZIP attachment response; missing services answer 500 and a
* missing root session 404 before any byte is produced.
*/
sessionLog(
request: { sessionId: SessionId; includeDescendants?: boolean },
signal: AbortSignal,
): Promise<Response>
}

View File

@@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { taskViewSchema } from './tasks.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question fields validated strictly against core dsh-user-interaction. */
@@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
message: messageSchema,
})),
}),
z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),

View File

@@ -14,6 +14,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 { TaskView } from './tasks.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
@@ -81,6 +82,20 @@ export type MuxFrame =
* in QueueDock, while pending steering renders at the conversation tail.
*/
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
* Complete set of background tasks this session can see, after every registry
* commit that changes it: registration, the stopping transition, settlement,
* and owner-disposal removal. The registry is process-local and holds no
* durable event, so — exactly like `session/queue` — the whole snapshot is
* what makes a start, a kill, a reconnect, and a second tab converge on one
* authoritative value.
*
* Sent as a subscription baseline only for a session that currently has
* tasks; an absent key means an empty set. A change that empties the set
* still sends `[]`, since that transition is the only one absence cannot
* express.
*/
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the

View File

@@ -16,9 +16,10 @@ import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts'
import type { DownloadsApi } from './downloads.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
export interface ApiProxy {
sessions: SessionsApi
subagents: SubagentsApi
@@ -32,6 +33,8 @@ export interface ApiProxy {
settings: SettingsApi
credentials: CredentialsApi
llm: LlmApi
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
downloads: DownloadsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
}
@@ -39,15 +42,15 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
SessionSearchItem,
SessionsApi, SessionSummary,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { TaskView } from './tasks.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
@@ -57,6 +60,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
export type { DownloadsApi } from './downloads.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -116,7 +116,7 @@ export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
* API; 'internal' as the catch-all code). Lives with RpcResult so every
* carrier consumer folds the same way.
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.

View File

@@ -1,7 +1,7 @@
/**
* sessions domain zod schemas (names derived from map keys: sessionListRequestSchema /
* sessionListValueSchema). SessionEvent passthrough = strict envelope (type/seq/time) + wide
* data: the merge-extensible event surface keeps an unknown-type branch at the union level,
* data: the merge-extensible event API keeps an unknown-type branch at the union level,
* with no field-level passthrough. SessionId brand cast point: sessionIdSchema, and only there.
*/

View File

@@ -0,0 +1,33 @@
/**
* tasks domain zod schemas: the branded task id and the wire view carried by
* `session/tasks` frames.
*/
import { z } from 'zod'
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
import type { TaskView } from './tasks.ts'
import type { Wire } from './rpc.schema.ts'
/** TaskId: one brand cast after non-empty string validation. */
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<TaskId>
/**
* One wire task view. `kind` stays an open string because producer plugins
* extend the registry's kind map by declaration merging, so the closed set is
* not knowable at this boundary.
*/
export const taskViewSchema = z.object({
id: taskIdSchema,
kind: z.string().min(1),
label: z.string().min(1),
status: z.union([
z.literal('running'),
z.literal('stopping'),
z.literal('completed'),
z.literal('killed'),
z.literal('failed'),
]),
detail: z.string().optional(),
startedAt: z.number().int().nonnegative(),
finishedAt: z.number().int().nonnegative().optional(),
}) satisfies z.ZodType<Wire<TaskView>>

View File

@@ -0,0 +1,36 @@
/**
* Browser-safe background-task domain contract. The registry's live records
* never cross the wire; a view is the subset a human list needs, minted fresh
* per push.
*/
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
/**
* One background task as the client sees it.
*
* Three registry fields are deliberately absent. `ownerSession` is redundant
* beside the frame's own `sessionId`; `reported` is an internal notice-delivery
* bit with no user meaning; `outputLimitBytes` is producer-owned model
* presentation policy that never reaches a human surface.
*/
export interface TaskView {
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
id: TaskId
/**
* Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare
* string because producer plugins extend the kind map by declaration merging,
* so no client build can enumerate the closed set.
*/
kind: string
/** Producer-supplied one-line label: the command, or the delegation description. */
label: string
/** Current lifecycle state. */
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */
detail?: string
/** Epoch ms when the task was registered. */
startedAt: number
/** Epoch ms when the task settled; absent while live. */
finishedAt?: number
}

View File

@@ -411,7 +411,7 @@ export abstract class AbstractApiClient implements IApiClient {
}
}
// ---- IApiClient surface (arrow properties so destructured/passed references stay bound) ----
// ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
readonly sessions: IApiClient['sessions'] = {
list: (payload, signal) => this.callUnary('session.list', payload, signal),

View File

@@ -9,6 +9,7 @@
import { randomUUID } from 'node:crypto'
import type { z } from 'zod'
import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
import { RpcId } from '../api/rpc.ts'
@@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
const url = new URL(req.url)
const path = url.pathname
// No-envelope GET channel surface (SSE streams + host-only download):
// physical routes that answer directly, without a wire envelope.
if (path === '/api/events.mux' && req.method === 'GET') {
return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
}
if (path === '/api/events.host' && req.method === 'GET') {
return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
}
if (path === '/api/session.export' && req.method === 'GET') {
// Query params are a different boundary from the POST envelope, but
// the request still casts its brands only through the domain schema.
const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return new Response('missing or invalid sessionId query parameter', { status: 400 })
}
return api.downloads.sessionLog(parsed.data, req.signal)
}
if (req.method !== 'POST' || !path.startsWith('/api/')) {
return new Response('not found', { status: 404 })

View File

@@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly credentials: ApiProxy['credentials']
readonly llm: ApiProxy['llm']
readonly events: ApiProxy['events']
readonly downloads: ApiProxy['downloads']
readonly respond: ApiProxy['respond']
constructor(ctx: Context, config: Config) {
@@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy {
this.credentials = api.credentials
this.llm = api.llm
this.events = api.events
this.downloads = api.downloads
// createApiProxy returns closures (no `this` capture), so the bind is
// behavior-neutral.
this.respond = api.respond.bind(api)

View File

@@ -0,0 +1,357 @@
/**
* Host-side session-log download: streams one ZIP archive whose files are the
* sessions' stored artifact text verbatim plus every referenced media object.
* The root artifact sits under its original base name (`session.jsonl`); each
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
* so one archive never duplicates a shared image). No manifest is written —
* every file is byte-identical to the backend's durable artifact or attachment
* store and self-describing through its own header line or media type.
* Compression runs on the host with fflate's streaming Zip API, so the archive
* bytes are produced incrementally and the host never holds the whole archive
* in one buffer; production yields to the consumer whenever the response queue
* fills past its high-water mark, so a slow consumer bounds the accumulation
* instead of piling up the whole archive (fflate's callback is synchronous —
* this drain point is the only backpressure available).
* @module
*/
import { Zip, ZipDeflate } from 'fflate'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
/** The services a session-log export needs (absent → the export is unavailable). */
export interface SessionLogExportDeps {
readonly sessionQuery: SessionQueryService | undefined
readonly sessionPersistence: SessionPersistence | undefined
readonly attachments: AttachmentStore | undefined
}
/** The export services narrowed to the mounted ones streaming actually reads. */
export interface SessionLogExportReady {
readonly sessionQuery: SessionQueryService
readonly sessionPersistence: SessionPersistence
readonly attachments: AttachmentStore
}
/**
* Resolve the persistence, session-query, and attachment services a log export needs.
* @param ctx - the composed host context.
* @returns the export services (absent when the deployment does not mount them).
*/
export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
return {
sessionQuery: ctx.get('sessionQuery'),
sessionPersistence: ctx.get('sessionPersistence'),
attachments: ctx.get('attachments'),
}
}
/** One exported file: a stored artifact text or one referenced media object. */
export type SessionLogZipEntry =
| { readonly path: string; readonly content: string }
| { readonly path: string; readonly data: Uint8Array }
/** Zip extension for each accepted raster media type. */
const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
}
/**
* The zip path for one media object: content-addressed by the opaque
* attachment id so shared images land once and the id in the log maps back to
* the archive entry without a manifest.
* @param ref - the durable reference from a session log.
* @returns the archive path.
*/
function mediaEntryPath(ref: ImageAttachmentRef): string {
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
}
/**
* Collect every image reference inside one content array, descending into
* nested tool results the way the live attachment route does.
* @param content - an event content array (or nested tool-result content).
* @param refs - the dedupe map being filled (keyed by attachment id).
*/
function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
if (!Array.isArray(content)) return
const pending: unknown[] = []
for (const item of content) pending.push(item)
while (pending.length > 0) {
const value = pending.pop()
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
refs.set(String(ref.attachmentId), ref)
}
if (Array.isArray(block.content)) {
for (const item of block.content) pending.push(item)
}
}
}
/**
* Collect every image reference one session event carries, across the same
* carriers the live attachment route scans (direct content, message content,
* inserted messages, and completed assistant chunk blocks).
* @param event - one parsed JSONL event object.
* @param refs - the dedupe map being filled (keyed by attachment id).
*/
function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
const data = (event as { data?: unknown }).data
if (typeof data !== 'object' || data === null) return
const carrier = data as {
content?: unknown
message?: { content?: unknown }
inserted?: Array<{ content?: unknown }>
chunk?: { type?: unknown; block?: unknown }
}
collectImageRefs(carrier.content, refs)
if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
if (carrier.inserted !== undefined) {
for (const message of carrier.inserted) collectImageRefs(message.content, refs)
}
if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
}
/**
* Collect the distinct media references one stored artifact text names.
* Lines that fail to parse cannot reference media and are skipped (the
* artifact text itself is exported verbatim regardless).
* @param content - the stored artifact text.
* @returns the dedupe map keyed by attachment id.
*/
function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
const refs = new Map<string, ImageAttachmentRef>()
for (const line of content.split('\n')) {
if (line === '') continue
let event: unknown
try {
event = JSON.parse(line)
} catch {
continue
}
collectEventImageRefs(event, refs)
}
return refs
}
/**
* One safe zip path segment from an untrusted session id. Session ids are
* host-controlled, but the brand allows any non-empty string, so `../`, dot
* segments, and separator characters are neutralized before they can shape
* archive entries. Distinct ids may collapse onto one segment (id collision
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
* @param id - the raw session id.
* @returns a filesystem-safe single path segment.
*/
function safeSessionIdSegment(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, '_')
}
/**
* The export archive filename for one root session.
* @param sessionId - the root session id (sanitized to one safe path segment).
* @returns the attachment filename for the session's export archive.
*/
export function sessionLogZipFilename(sessionId: string): string {
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
}
/**
* Yield the export entries in zip order: the preloaded root artifact first,
* then every subagent descendant in lineage order (each read from the
* persistence backend right before it is yielded and dropped after the
* consumer moves on), then every distinct media object referenced by any of
* the included logs (read and verified from the attachment store, one archive
* entry per attachment id). The host holds at most one descendant's artifact
* text and one media object at a time beyond the root.
* @param deps - the mounted export services (the caller answered 500 before this runs).
* @param root - the already-read root artifact (read by the caller so the
* missing-session path can answer cleanly before streaming starts).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param signal - optional cancellation for read work.
* @returns the export entries in zip order.
*/
export async function* sessionLogZipEntries(
deps: SessionLogExportReady,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
signal?: AbortSignal,
): AsyncGenerator<SessionLogZipEntry> {
const media = new Map<string, ImageAttachmentRef>()
const rememberMedia = (content: string): void => {
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
}
rememberMedia(root.content)
yield { path: root.filename, content: root.content }
if (includeDescendants) {
const seen = new Set<SessionId>([sessionId])
const collect = async function* (
nodes: readonly SessionLineageNode[],
): AsyncGenerator<SessionLogZipEntry> {
for (const node of nodes) {
signal?.throwIfAborted()
const id = node.session.header.id
if (seen.has(id)) continue
seen.add(id)
const raw = await deps.sessionPersistence.readRaw(id)
if (raw === undefined) {
throw new Error(`subagent "${id}" has no stored log artifact`)
}
rememberMedia(raw.content)
yield {
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
content: raw.content,
}
yield* collect(node.descendants)
}
}
const lineage = await deps.sessionQuery.traceSession(sessionId)
yield* collect(lineage.descendants)
}
for (const ref of media.values()) {
signal?.throwIfAborted()
const stored = await deps.attachments.readImage(ref)
yield { path: mediaEntryPath(ref), data: stored.data }
}
}
/** How many code units of artifact text one zip push carries (bounded encode memory). */
const PUSH_CHUNK_CODE_UNITS = 1 << 16
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
const PUSH_CHUNK_BYTES = 1 << 16
/**
* Push one media object's bytes into a deflate stream in bounded chunks,
* yielding to a slow consumer between chunks like the artifact path does.
* @param deflate - the zip entry's deflate stream.
* @param data - the stored image bytes.
* @param signal - optional cancellation; throws when aborted.
*/
async function pushBinaryChunks(
deflate: ZipDeflate,
data: Uint8Array,
controller: ReadableStreamDefaultController<Uint8Array>,
signal?: AbortSignal,
): Promise<void> {
let offset = 0
do {
signal?.throwIfAborted()
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
const finalChunk = end >= data.byteLength
deflate.push(data.subarray(offset, end), finalChunk)
offset = end
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
if (controller.desiredSize !== null && controller.desiredSize < 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
} while (offset < data.byteLength)
}
/**
* Push one artifact's text into a deflate stream in bounded chunks, never
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
* @param deflate - the zip entry's deflate stream.
* @param content - the artifact text verbatim.
* @param signal - optional cancellation; throws when aborted.
*/
async function pushArtifactChunks(
deflate: ZipDeflate,
content: string,
controller: ReadableStreamDefaultController<Uint8Array>,
signal?: AbortSignal,
): Promise<void> {
const encoder = new TextEncoder()
let offset = 0
let finalChunk: boolean
do {
signal?.throwIfAborted()
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
if (end < content.length && end - offset > 1) {
// Back off one code unit when the boundary lands inside a surrogate
// pair: the pair then starts the next chunk whole.
const last = content.charCodeAt(end - 1)
if (last >= 0xd800 && last <= 0xdbff) end -= 1
}
finalChunk = end >= content.length
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
offset = end
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
if (controller.desiredSize !== null && controller.desiredSize < 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
} while (!finalChunk)
}
/**
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
* read and validated by the caller before this is called (missing root or
* missing services answer cleanly before any byte is produced); each entry is
* then encoded and deflated in bounded chunks as it is produced, so the
* archive bytes arrive incrementally. A descendant that fails to read errors
* the stream (fail-loud, never silent under-export).
* @param deps - the mounted export services (the caller answered 500 before this runs).
* @param root - the already-read root artifact (first zip entry).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param signal - optional cancellation for read work.
* @returns the zip byte stream.
*/
export function streamSessionLogZip(
deps: SessionLogExportReady,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
signal?: AbortSignal,
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
// fflate invokes the callback synchronously per compressed chunk, so a
// single push can enqueue ahead of a slow consumer; pushArtifactChunks
// yields between chunks once the queue is over-full, bounding the
// accumulation to the queue high-water mark plus one push.
const zip = new Zip((error, data, final) => {
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
if (error) {
controller.error(error)
return
}
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
if (data.byteLength > 0) controller.enqueue(data)
if (final) controller.close()
})
void (async () => {
try {
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) {
const deflate = new ZipDeflate(entry.path, { level: 6 })
zip.add(deflate)
if ('content' in entry) {
await pushArtifactChunks(deflate, entry.content, controller, signal)
} else {
await pushBinaryChunks(deflate, entry.data, controller, signal)
}
}
zip.end()
} catch (error) {
// A mid-stream failure (missing descendant, cancellation, read
// error) must fail the download rather than ship a truncated archive.
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})()
},
})
}

View File

@@ -0,0 +1,263 @@
/**
* Background-task carrier paths of the host ApiProxy: the subscription
* baseline is sent only for a session that has tasks, every registry change
* pushes that owner's whole set, an unowned change fans out to every
* subscribed session, the projection drops the three internal snapshot
* fields, a composition without `ctx.tasks` emits nothing, and listing never
* resumes a cold session.
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
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'
type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
/**
* A producer whose settlement the test drives. `cancel` deliberately does not
* settle, so a kill is observable as the distinct `stopping` step before the
* test supplies the terminal outcome and its detail.
*/
function producer(label = 'sleep 60') {
let settle!: (outcome: TaskOutcome) => void
// A stream producer, so the carrier CAN consume the cursor if it ever calls
// `read()`; `reads` is what proves it never does.
const reads = { count: 0 }
const spec = {
kind: 'bash' as const,
label,
run: () => ({
cancel: () => {},
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
readOutput: () => { reads.count += 1; return 'stolen output' },
}),
}
return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } }
}
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
if (withRegistry) {
await ctx.plugin(LocalTaskService)
ctx.tasks.attachController('api-proxy-test')
}
const session = ctx.sessions.create()
const agent = {
id: session.id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
return { ctx, session, agent }
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
async function collect(
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
count: number,
abort: AbortController,
): Promise<TaskFrame[]> {
const frames: MuxFrame[] = []
for await (const envelope of iterable) {
frames.push(envelope.payload)
if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
}
return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
}
describe('session/tasks subscription baseline', () => {
it('is omitted for a session with no tasks — absence is the empty set', async () => {
const { ctx, session } = await harness(true)
const abort = new AbortController()
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal)
const frames: MuxFrame[] = []
const drained = (async () => {
for await (const envelope of stream) {
frames.push(envelope.payload)
if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort()
}
})()
await drained
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
void session
})
it('carries the live set for a session that already has tasks when the stream opens', async () => {
const { ctx, session, agent } = await harness(true)
ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
const abort = new AbortController()
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
const [baseline] = await collect(stream, 1, abort)
expect(baseline?.sessionId).toBe(session.id)
expect(baseline?.tasks).toHaveLength(1)
const [task] = baseline?.tasks ?? []
expect(task?.startedAt).toBeTypeOf('number')
expect({ ...task, startedAt: 0 }).toEqual({
id: 'bash-1',
kind: 'bash',
label: 'pnpm run build',
status: 'running',
startedAt: 0,
})
})
})
describe('session/tasks change pushes', () => {
it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
const { ctx, session, agent } = await harness(true)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal)
const collected = collect(stream, 3, abort)
const p = producer()
const id = ctx.tasks.start({ ...p.spec, owner: agent })
ctx.tasks.kill(id, agent, 'test')
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
const frames = await collected
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
// Terminal detail rides the same whole-set push; no separate signal.
expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
})
it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
const { ctx, agent } = await harness(true)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
const collected = collect(stream, 1, abort)
ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
const [frame] = await collected
const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
})
it('fans an unowned change out to every subscribed session', async () => {
const { ctx } = await harness(true)
const second = ctx.sessions.create()
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
const collected = collect(stream, 2, abort)
ctx.tasks.start(producer('open to every caller').spec)
const frames = await collected
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
})
it('serves a cold session the unowned set without resuming it', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('session-cold-tasks')
let loaded = false
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
load: () => { loaded = true; throw new Error('task listing must not load a cold log') },
} as never)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
const collected = collect(stream, 1, abort)
ctx.tasks.start(producer().spec)
await collected
expect(loaded).toBe(false)
expect(ctx.agents.get(coldId)).toBeUndefined()
})
})
describe('session/tasks without the registry', () => {
it('emits no frames at all, so the client renders no entry point', async () => {
const { ctx, session } = await harness(false)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal)
const frames: MuxFrame[] = []
const drained = (async () => {
for await (const envelope of stream) {
frames.push(envelope.payload)
if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort()
}
})()
session.append('turn/start', { turn: 1 })
await drained
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
})
})
describe('session/tasks never consumes model output', () => {
it('drives the whole lifecycle without calling the single consuming cursor', async () => {
// `ctx.tasks.read()` consumes the one output cursor, so a carrier read
// silently takes bytes the model's `task_output` will never see. The
// failure is invisible at the call site, which is why this asserts the
// count rather than trusting review.
const { ctx, agent } = await harness(true)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal)
const collected = collect(stream, 3, abort)
const p = producer()
const id = ctx.tasks.start({ ...p.spec, owner: agent })
ctx.tasks.kill(id, agent, 'test')
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await collected
expect(p.reads.count).toBe(0)
})
it('reads nothing while minting the subscription baseline either', async () => {
const { ctx, agent } = await harness(true)
const p = producer()
ctx.tasks.start({ ...p.spec, owner: agent })
const abort = new AbortController()
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal)
const [baseline] = await collect(stream, 1, abort)
expect(baseline?.tasks).toHaveLength(1)
expect(p.reads.count).toBe(0)
})
})
describe('session/tasks baseline for a session born after the stream opened', () => {
it('carries the already-visible unowned set to the new session', async () => {
const { ctx } = await harness(true)
const proxy = api(ctx)
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal)
// One unowned task exists before the new session is created; the subscribe
// frame clears the client mirror, so the baseline has to follow it.
ctx.tasks.start(producer('visible to every caller').spec)
const created = ctx.sessions.create()
const frames = await collect(stream, 2, abort)
const forNew = frames.filter(frame => frame.sessionId === created.id)
expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller')
})
})

View File

@@ -133,6 +133,7 @@ function scriptedApi(overrides: {
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
}
}

View File

@@ -300,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async respond(message: ClientResponse): Promise<RpcReceipt> {
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
},
downloads: {
async sessionLog() {
return new Response('stub', { status: 404 })
},
},
}
}

View File

@@ -436,6 +436,11 @@ describe('events frame schemas', () => {
},
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'session/tasks', sessionId: 's', tasks: [] },
{ type: 'session/tasks', sessionId: 's', tasks: [
{ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 },
{ id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 },
] },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
@@ -444,6 +449,14 @@ describe('events frame schemas', () => {
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
// A producer kind stays an open string, but the closed status set and
// the identity/label bounds are the carrier's own wire contract.
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
})

View File

@@ -0,0 +1,377 @@
/**
* session.export host path: the GET download endpoint streams a ZIP whose
* files are the stored artifacts verbatim (root + optional descendants), and
* the degenerate compositions fail loudly (missing services → 500, missing
* root → 404, missing descendant → errored stream).
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { unzipSync, strFromU8 } from 'fflate'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
function header(id: string, parentSession?: SessionId): SessionHeader {
return {
version: 0,
id: sid(id),
createdAt: 1000,
cwd: '/proj',
...parentSession === undefined ? {} : { parentSession },
delegationDepth: parentSession === undefined ? 0 : 1,
}
}
function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
return {
meta: header(id, parentSession),
filename: 'session.jsonl',
content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
}
}
function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
}
/** One durable image object served by the fake attachment store. */
function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
return {
ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
data: new Uint8Array([1, 2, 3, 4]),
}
}
/** A user/message event line carrying one image reference. */
function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
}
async function buildApi(
artifacts: Record<string, SessionRawArtifact>,
descendants: SessionLineageNode[] = [],
services: {
query?: boolean
persistence?: boolean | 'throw'
attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
} = {},
) {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const query = services.query ?? true
const persistence = services.persistence ?? true
if (query) {
ctx.provide('sessionQuery', {
traceSession: async () => ({
target: { header: header('session-root'), live: false, persisted: true },
ancestors: [],
complete: true,
root: { header: header('session-root'), live: false, persisted: true },
descendants,
}),
} as never)
}
if (persistence) {
ctx.provide('sessionPersistence', {
readRaw: async (id: SessionId) => {
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
return artifacts[id]
},
} as never)
}
if (services.attachments !== false) {
const readImage = typeof services.attachments === 'function'
? services.attachments
: async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
ctx.provide('attachments', {
imageLimits: {} as never,
validateImage: async () => {},
saveImage: async () => { throw new Error('export never saves images') },
readImage,
} as never)
}
return createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
}
async function responseBytes(response: Response): Promise<Uint8Array> {
return new Uint8Array(await response.arrayBuffer())
}
describe('session.export download endpoint', () => {
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual(['session.jsonl'])
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
})
it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
'child-a': artifact('child-a', sid('session-root')),
'grandchild-a': artifact('grandchild-a', sid('child-a')),
}, [
node('child-a', node('grandchild-a')),
])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
expect(response.status).toBe(200)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'session.jsonl',
'subagents/child-a/session.jsonl',
'subagents/grandchild-a/session.jsonl',
])
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
.toBe(artifact('child-a').content)
})
it('answers 404 for a missing root session', async () => {
const api = await buildApi({})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(404)
})
it('answers 400 when the sessionId query parameter is absent', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?includeDescendants=true'),
)
expect(response.status).toBe(400)
})
it('answers 400 for an includeDescendants value other than true or false', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
)
expect(response.status).toBe(400)
})
it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
const api = await buildApi({}, [], { query: false, persistence: false })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
expect(await response.text()).toContain('session-query')
})
it('fails the whole export when a descendant has no stored artifact', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
}, [node('child-missing')])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
expect(response.status).toBe(200)
// The stream errors before completing, so the body read rejects rather
// than returning a truncated-but-valid archive.
await expect(response.arrayBuffer()).rejects.toThrow()
})
it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
// The push loop slices by 2^16 code units and must back off one unit when
// the boundary lands inside a surrogate pair; otherwise the pair re-encodes
// as U+FFFD and the exported artifact is silently corrupted.
const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
// A boundary that lands on a BMP character needs no surrogate backoff; the
// round trip must still be byte-identical across the multi-chunk push.
const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('exports an empty artifact as an empty zip entry', async () => {
const root = { ...artifact('session-root'), content: '' }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual(['session.jsonl'])
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
})
it('exports a shared lineage node once (seen-set dedup)', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
'child-a': artifact('child-a', sid('session-root')),
'child-b': artifact('child-b', sid('session-root')),
shared: artifact('shared', sid('child-a')),
}, [
node('child-a', node('shared')),
node('child-b', node('shared')),
])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'session.jsonl',
'subagents/child-a/session.jsonl',
'subagents/child-b/session.jsonl',
'subagents/shared/session.jsonl',
])
})
it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
const api = await buildApi({}, [], { query: true, persistence: 'throw' })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
const body = await response.text()
expect(body).toBe('session log export failed to read the stored artifact')
expect(body).not.toContain('/host/private/')
})
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
imageEventLine('img-1'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(200)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
})
it('collects media referenced from nested tool results', async () => {
const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}'
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
nested,
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
})
it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
const block = (id: string, mediaType: string) =>
`{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
wrapped,
inserted,
chunk,
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'media/chunk-1.png',
'media/inserted-1.gif',
'media/wrapped-1.jpg',
'session.jsonl',
])
})
it('deduplicates one media object referenced by several included logs', async () => {
const line = imageEventLine('shared-img')
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
line,
].join('\n') + '\n')
const child = artifact('child-a', sid('session-root'), [
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
line,
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
const files = unzipSync(await responseBytes(response))
expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
})
it('includes descendant media only when descendants are requested', async () => {
const child = artifact('child-a', sid('session-root'), [
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
imageEventLine('child-img'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
const without = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
const withDescendants = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
'media/child-img.png',
'session.jsonl',
'subagents/child-a/session.jsonl',
])
})
it('fails the whole export when a referenced image cannot be read', async () => {
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
imageEventLine('gone-img'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root }, [], {
attachments: async () => { throw new Error('attachment bytes missing') },
})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(200)
await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
})
it('answers 500 when the deployment mounts no attachments service', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
expect(await response.text()).toContain('attachments')
})
})

View File

@@ -74,6 +74,9 @@
{
"path": "../../skill/skill"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../interaction/commands"
},