Merge remote-tracking branch 'origin/master' into worktree/web-plugin-config

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 15:50:49 +08:00
352 changed files with 7220 additions and 781 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: 01291e05ffdaaca6e5766f3bd7225365e98de1c5
README.zh.md: 22421f88b575bb2802354a78527431f3b9c864c4
README.md: 75763f8826dc0161ce7ec85aaf5806b184efa918
README.zh.md: bbbdee3441f8bfd443a69d8e28e8ffe1eae90cc9

View File

@@ -50,13 +50,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
## 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)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package.
`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)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package.
## Model Experience

View File

@@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`agentPreset.read``copy``openDocument``remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid``remove` 对随附 preset 回答 `agent-preset-read-only``openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径因此没有任何浏览器载荷能选中任意文件系统目标部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list``select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答,因此每一种前端web、TUIACPAgent Client Protocol、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答,因此所有入口Web、TUIACPAgent Client Protocol共享同一条确定性路径手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed`注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope不产生任何注册因此该会话组成所决定的两份目录`command.list``skill.list`)都会失效,却没有任何注册表变化来宣告它。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale``permission``ui-conversation``ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale``permission``ui-conversation``ui-theme``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。

View File

@@ -38,6 +38,7 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",

View File

@@ -10,9 +10,11 @@ import type { Context } from 'cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
@@ -35,7 +37,7 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
@@ -102,6 +104,113 @@ const COLD_SUMMARY_BATCH_SIZE = 16
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Decode the browser payload while rejecting non-canonical base64 forms. */
function decodeBase64(data: string): Uint8Array {
const decoded = Buffer.from(data, 'base64')
if (data.length === 0 || decoded.toString('base64') !== data) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
return new Uint8Array(decoded)
}
/** Validate one prompt as a batch before publishing any durable image object. */
async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
const limits = ctx.attachments.imageLimits
if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
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)
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')
}
for (const image of images) {
await ctx.attachments.validateImage({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})
}
const blocks: ContentBlock[] = []
for (const item of prepared) {
if (!('data' in item)) {
blocks.push({ type: 'text', text: item.text })
continue
}
const attachment = await ctx.attachments.saveImage({
data: item.data,
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
blocks.push({ type: 'image', attachment })
}
return blocks
}
/** Search durable content for an image reference, including nested tool results. */
function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): 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 (match(ref)) return ref
}
if (block.type === 'tool-result') {
const nested = imageBlockIn(block.content, match)
if (nested !== undefined) return nested
}
}
return undefined
}
/** Search every durable event carrier that can own model-visible content. */
function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
const data = event.data as {
content?: unknown
message?: { content?: unknown }
inserted?: Array<{ content?: unknown }>
chunk?: { type?: unknown; block?: unknown }
}
const direct = imageBlockIn(data.content, match)
if (direct !== undefined) return direct
if (data.message !== undefined) {
const wrapped = imageBlockIn(data.message.content, match)
if (wrapped !== undefined) return wrapped
}
if (data.inserted !== undefined) {
for (const message of data.inserted) {
const inserted = imageBlockIn(message.content, match)
if (inserted !== undefined) return inserted
}
}
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
return imageBlockIn([data.chunk.block], match)
}
return undefined
}
/** True when the current model-visible surface contains an image. */
function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
return messages.some(message => contentHasImage(message.content))
}
/** Resolve the first reference matching one opaque id. */
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
for (const event of events) {
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
if (found !== undefined) return found
}
return undefined
}
/**
* Product settings intentionally exposed beside model-provider namespaces.
*
@@ -869,6 +978,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
const imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
/** Serialize image admission with model selection for one agent. */
function serializeImageAdmission<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
return result
}
/**
* Install or return the session-local model selection that prompt assembly snapshots.
@@ -1931,41 +2048,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const { sessionId, provider, model, reasoningEffort } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
const resolved = await ctx.llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
const selected: ModelSelection = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
selectionFor(found.agent).current = selected
// A switch is also how this deployment's default is chosen: the next
// session created without one of its own starts here. Sessions that
// have already logged a selection are unaffected — they derive from
// their own log (see selectionFor).
return serializeImageAdmission(found.agent, async () => {
try {
await defaults.saveDefaultModelSelection?.(selected)
const resolved = await ctx.llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep]
.some(message => contentHasImage(message.content))
if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) {
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
return err(request, {
code: 'model-unavailable',
message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`,
details: { provider, model },
})
}
}
const selected: ModelSelection = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
selectionFor(found.agent).current = selected
try {
await defaults.saveDefaultModelSelection?.(selected)
} catch (error: unknown) {
ctx.logger.warn(
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
)
}
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
ctx.logger.warn(
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
)
return err(request, {
code: 'model-unavailable',
message: error instanceof Error ? error.message : String(error),
details: { provider, model },
})
}
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
code: 'model-unavailable',
message: error instanceof Error ? error.message : String(error),
details: { provider, model },
})
}
})
},
async rename(request) {
@@ -2103,19 +2230,101 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const agent = resolved.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 {
const message: UserMessage = createUserMessage({ content, source })
if (mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error: unknown) {
// A synchronous throw from steer/followup 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) } })
const hasImage = content.some(part => part.type === 'image')
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
try {
if (hasImage) {
const current = selectionFor(agent).current
const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model)
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
return err(request, {
code: 'attachment-error',
message: `Model "${current.model}" does not support image input.`,
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
})
}
}
const durable = await durablePromptContent(ctx, content)
const message: UserMessage = createUserMessage({ content: durable, source })
if (mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
return err(request, {
code: 'agent-busy',
message: 'prompt rejected',
details: { reason: String(error) },
})
}
return ok(request, { accepted: true as const })
}
return hasImage ? serializeImageAdmission(agent, admit) : admit()
},
async attachment(request) {
const { sessionId, attachmentId } = request.payload
let state: SessionReadState
try {
state = await readSessionState(sessionId)
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return err(request, {
code: 'session-not-found',
message: error.message,
details: { sessionId },
})
}
return err(request, {
code: 'internal',
message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`,
details: {},
})
}
const ref = referencedImage(state.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: {},
})
}
return ok(request, { accepted: true as const })
},
updateQueue(request) {
const { sessionId, itemId, action } = request.payload
if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) {
return Promise.resolve(err(request, {
code: 'attachment-error',
message: 'queue edits accept text content only',
details: { reason: 'QUEUE_EDIT_NON_TEXT' },
}))
}
const agent = ctx.agents.get(sessionId)
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
@@ -3164,6 +3373,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.on('commands/change', () => {
queue.push(frame({ type: 'host/commands-changed' }))
}),
// The recompose itself registers nothing (it re-parents the agent's
// scope onto a standing mount that may already exist), so the
// logged selection is the only commit point a client can follow.
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'agent-preset/selected') return
queue.push(frame({
type: 'host/session-preset-changed',
sessionId: session.id,
agentPreset: event.data.agentPreset,
}))
}),
ctx.on('settings/document-updated', (ns) => {
// The RAW-section event, not the resolved one: a field going from
// inherited to overridden leaves the resolved value equal, and a

View File

@@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
z.object({ type: z.literal('host/commands-changed') }),
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
z.object({ type: z.literal('host/models-changed') }),

View File

@@ -130,6 +130,18 @@ export type HostFrame =
* background rather than diffing.
*/
| { type: 'host/commands-changed' }
/**
* One blank session was recomposed onto another agent preset (the logged
* `agent-preset/selected` commit point, read off the session stream). The
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
* re-parents that agent's scope without registering anything, so a
* preset already mounted for another session produces no registry change
* at all. Clients refetch the catalogs this session's composition decides
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
* preset id into their session row — the RPC echo reaches only the client
* that issued the switch, so the row is where every other one learns it.
*/
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
/**
* One settings namespace's resolved value changed (`settings/updated`
* passthrough) — an RPC write, an external `settings.yaml` edit, or a

View File

@@ -17,8 +17,6 @@ export const hostDescribeValueSchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
// Open string, not a literal union: unknown kinds must survive the wire so
// a merge-added capability can advertise (the client hides the affordance).
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */

View File

@@ -39,7 +39,8 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'

View File

@@ -32,6 +32,7 @@ export interface RpcMethodMap {
'session.rename': SessionsApi['rename']
'session.fork': SessionsApi['fork']
'session.prompt': SessionsApi['prompt']
'session.attachment': SessionsApi['attachment']
'session.updateQueue': SessionsApi['updateQueue']
'session.cancel': SessionsApi['cancel']
'subagent.list': SubagentsApi['list']

View File

@@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: 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('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),

View File

@@ -50,6 +50,7 @@ export interface RpcErrorDetailsMap {
'agent-preset-not-found': { agentPreset: string; available: string[] }
'agent-preset-invalid': { agentPreset: string; reason: string }
'agent-busy': { reason: string }
'attachment-error': { reason: string }
'queue-item-not-found': { itemId: MessageId }
'steer-unavailable': { itemId: MessageId }
/** A known slash command reported a usage/state error; the message is the command's own text. */

View File

@@ -15,6 +15,7 @@ import type {
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, 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'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -249,11 +250,25 @@ export const sessionSelectModelValueSchema = z.object({
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })
/** Raster image media types accepted by the version-one browser wire. */
export const imageMediaTypeSchema = z.union([
z.literal('image/png'),
z.literal('image/jpeg'),
z.literal('image/webp'),
z.literal('image/gif'),
])
/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */
export const promptContentPartSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }),
])
/** session.prompt request payload. */
export const sessionPromptRequestSchema = z.object({
sessionId: sessionIdSchema,
mode: z.union([z.literal('queue'), z.literal('steer')]),
content: z.array(contentBlockSchema),
content: z.array(promptContentPartSchema),
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
@@ -265,6 +280,31 @@ export const sessionPromptValueSchema = z.object({
}).optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
/** Opaque attachment id after string-shape validation. */
export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType<AttachmentIdType>
/** Durable image reference returned from the authenticated session lookup. */
export const imageAttachmentRefSchema = z.object({
attachmentId: attachmentIdSchema,
mediaType: imageMediaTypeSchema,
bytes: z.number().int().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
name: z.string().optional(),
}) as unknown as z.ZodType<ImageAttachmentRef>
/** session.attachment request payload. */
export const sessionAttachmentRequestSchema = z.object({
sessionId: sessionIdSchema,
attachmentId: attachmentIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.attachment'>>>
/** session.attachment response value. */
export const sessionAttachmentValueSchema = z.object({
attachment: imageAttachmentRefSchema,
data: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.attachment'>>>
/** session.updateQueue request payload. */
export const sessionUpdateQueueRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -5,6 +5,7 @@
*/
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// The pure-type outlet: api/ is browser-importable, and the package root's
@@ -53,6 +54,11 @@ export interface SessionProjectionsBlock {
values: Partial<SessionProjectionMap>
}
/** Browser-submitted prompt content; the host promotes image bytes to durable references. */
export type PromptContentPart =
| { type: 'text'; text: string }
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string }
/** Complete model selection for one session. */
export interface ModelSelection {
/** Registered provider route. */
@@ -302,10 +308,14 @@ export interface SessionsApi {
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
/** Sends text and temporary image bytes after durable host admission. Session-backed subagents reject with `agent-busy`. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/** Reads one durable image after proving that this session's log references its id. */
attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }>):
Promise<RpcResponse<{ attachment: ImageAttachmentRef; data: string }>>
/**
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
* Session-backed subagents reject with `agent-busy`.

View File

@@ -19,6 +19,7 @@ import {
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionAttachmentValueSchema,
sessionCreateValueSchema,
sessionForkValueSchema,
sessionHistoryValueSchema,
@@ -94,6 +95,7 @@ export interface IApiClient {
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
@@ -180,6 +182,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.rename': sessionRenameValueSchema,
'session.fork': sessionForkValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.attachment': sessionAttachmentValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'subagent.list': subagentListValueSchema,
@@ -420,6 +423,7 @@ export abstract class AbstractApiClient implements IApiClient {
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal),
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}

View File

@@ -16,6 +16,7 @@ import type { Wire } from '../api/rpc.schema.ts'
import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
import {
sessionCancelRequestSchema,
sessionAttachmentRequestSchema,
sessionCreateRequestSchema,
sessionForkRequestSchema,
sessionHistoryRequestSchema,
@@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },

View File

@@ -8,7 +8,7 @@
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
* shared with direct front doors. Switching models persists through that
* shared with direct entry points. Switching models persists through that
* service; sessions that have already logged a selection remain unchanged.
*/
@@ -55,7 +55,7 @@ export interface Config {
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
'agentDefaultModel', 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]

View File

@@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
import type { HostFrame } from '../src/api/events.ts'
import {
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
@@ -350,6 +351,37 @@ describe('agentPreset.select', () => {
.toBe('core-web')
})
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
// The host-stream opener reads the committed-workspace baseline; this
// spec owns preset identity, so the stub suffices (api-proxy-commands
// precedent).
ctx.provide('workspace', { list: () => [] } as never)
const abort = new AbortController()
const frames: HostFrame[] = []
const stream = api.events.host(request({}), abort.signal)
const consume = (async () => {
for await (const frame of stream) {
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
}
})()
await api.agentPresets.select(
request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
// The queue push rides the synchronous append, so one turn of the loop is
// enough to deliver it; closing the stream bounds the read either way.
await new Promise(resolve => setTimeout(resolve, 0))
abort.abort()
await consume
// Recomposing registers nothing, so this frame — not the registry-wide
// commands one — is what tells a client its cached catalogs are stale.
expect(frames).toEqual([
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
])
})
it('serializes two concurrent selects on one session', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))

View File

@@ -5,7 +5,7 @@
* boundary for a running selection change.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -13,6 +13,7 @@ import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -108,7 +109,8 @@ async function harness(logged?: {
session,
status: 'running',
ctx,
} as Agent
inbox: { nextTurn: [], nextStep: [] },
} as unknown as Agent
ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
@@ -118,7 +120,157 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
return response.result.value
}
function registerTextOnly(ctx: Context): void {
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
}
}('Text Only', []))
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...input.name === undefined ? {} : { name: input.name },
}))
ctx.provide('attachments', {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
mediaTypes: ['image/png'],
},
validateImage,
saveImage,
} as never)
const followup = vi.fn()
Object.assign(agent, { followup })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const result = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
{ type: 'text' as const, text: 'compare' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
],
}))
expect(result.result.ok).toBe(true)
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{
type: 'image',
attachment: {
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
},
},
{ type: 'text', text: 'compare' },
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
])
const denied = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: Array.from({ length: 3 }, () => ({
type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
})),
}))
expect(denied.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
})
expect(saveImage).toHaveBeenCalledTimes(2)
await ctx.fiber.dispose()
})
it('refuses a text-only selection while durable or pending image content remains visible', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const image = {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
} as never, { surfaceOp: 'append' })
expect((await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
agent.session.append('user/message', {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
sourceEventSeqs: agent.session.events.map(event => event.seq),
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
} as never)
expect((await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).result.ok).toBe(false)
;(agent.inbox.nextTurn as UserMessage[]).length = 0
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
}
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
agent.session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: 'queued-image', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: ref }],
}],
} as never)
const allowed = await api.sessions.attachment(request({
sessionId, attachmentId: 'att-authorized' as never,
}))
expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
const denied = await api.sessions.attachment(request({
sessionId, attachmentId: 'att-other' as never,
}))
expect(denied.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
})
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',

View File

@@ -56,6 +56,10 @@ function scriptedApi(overrides: {
rename: r => ok(r, { title: 'renamed', seq: 0 }),
fork: r => ok(r, { sessionId: sid('s-fork') }),
prompt: r => ok(r, { accepted: true as const }),
attachment: r => ok(r, {
attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: 'AA==',
}),
updateQueue: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,

View File

@@ -97,6 +97,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async attachment(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } },
}
},
async updateQueue(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -357,6 +363,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,

View File

@@ -493,6 +493,7 @@ describe('events frame schemas', () => {
} },
{ type: 'host/workspace-removed', workspaceId: 'w' },
{ type: 'host/commands-changed' },
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })

View File

@@ -29,6 +29,9 @@
{
"path": "../../util/brand"
},
{
"path": "../../attachment/attachment"
},
{
"path": "../../llm/llm"
},