Merge remote-tracking branch 'refs/remotes/origin/worktree/web-multimodal-image-input' into worktree/pr555-simplify

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
#	apps/cli/src/app-cli-entry.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/api/host.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-30 01:16:58 +08:00
97 changed files with 1375 additions and 447 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: b5f80dcb3a077a411db3b721737a9c16b56fcecf
README.zh.md: 1c3c7486f7d500f8c2d36028d47f29b112d9b5ae
README.md: 1f0daedc54888a1951bc83c474f83287aaf42307
README.zh.md: abf5417cdbe93f1199c621ac101249986969da93

View File

@@ -22,7 +22,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
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). `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; the carrier's request 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 browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `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; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)

View File

@@ -22,7 +22,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)

View File

@@ -1425,10 +1425,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
cwd: defaults.cwd,
provider: defaults.provider,
model: defaults.model,
imageLimits: {
...ctx.attachments.imageLimits,
mediaTypes: [...ctx.attachments.imageLimits.mediaTypes],
},
attachedSessions: ctx.agents.list().length,
}))
},
@@ -1642,7 +1638,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
}
try {
const skills = await skillRegistry.list({ cwd })
const skills = (await skillRegistry.list({ cwd }))
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,

View File

@@ -6,7 +6,6 @@ import { z } from 'zod'
import type { DirectoryEntry } from './host.ts'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { imageMediaTypeSchema } from './sessions.schema.ts'
/** host.describe request payload (empty object literal). */
export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.describe'>>>
@@ -17,13 +16,6 @@ export const hostDescribeValueSchema = z.object({
cwd: z.string(),
provider: z.string().optional(),
model: z.string().optional(),
imageLimits: z.object({
maxImageBytes: z.number().int().positive(),
maxImagesPerMessage: z.number().int().positive(),
maxMessageImageBytes: z.number().int().positive(),
maxImagePixels: z.number().int().positive(),
mediaTypes: z.array(imageMediaTypeSchema),
}).optional(),
attachedSessions: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>

View File

@@ -4,7 +4,6 @@
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
export interface DirectoryEntry {
@@ -48,8 +47,6 @@ export interface HostApi {
cwd: string
provider?: string
model?: string
/** Resolved authoritative image-upload limits. */
imageLimits?: ImageAttachmentLimits
attachedSessions: number
}>>

View File

@@ -20,6 +20,6 @@ export interface SkillEntry {
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
export interface SkillsApi {
/** Lists model-invocable skills for the addressed session's project root. */
/** Lists skills usable by the browser's user-selected model-reference path. */
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

View File

@@ -184,10 +184,28 @@ describe('skill.list', () => {
name: 'probe',
list: (options) => {
seenCwds.push(options.cwd)
return Promise.resolve([{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
source: 'custom', provider: 'probe', rank: 0, locator: null,
}])
return Promise.resolve([
{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
invocation: { modelInvocable: true, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'user-only', description: 'User-only',
invocation: { modelInvocable: false, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'model-only', description: 'Model-only',
invocation: { modelInvocable: true, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'trusted-only', description: 'Trusted-only',
invocation: { modelInvocable: false, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
])
},
get: () => Promise.resolve(undefined),
}))

View File

@@ -7,12 +7,6 @@ import { RpcId } from '../src/api/rpc.ts'
import { toFetchHandler } from '../src/fetch/handler.ts'
import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
declare module '@deepseek-ai/dsh-llm' {
interface ModelModalityMap {
audio: 'audio'
}
}
/** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */
function fakeApi(overrides: Partial<{
muxFrames: MuxFrame[]
@@ -259,26 +253,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect((await c.host.describe({})).result.ok).toBe(true)
})
it('round-trips a declaration-merged model modality through host.describe', async () => {
const c = client(fakeApi({
hostDescription: {
version: 'v',
cwd: '/w',
attachedSessions: 0,
},
}))
const response = await c.host.describe({})
expect(response.result).toEqual({
ok: true,
value: {
version: 'v',
cwd: '/w',
attachedSessions: 0,
},
})
})
it('round-trips the native picker without the default unary timeout', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request) => {

View File

@@ -242,7 +242,7 @@ describe('sessions domain schemas', () => {
})
describe('host domain schemas', () => {
it('validates describe request/value and preserves merge-extensible modalities', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
const value = hostDescribeValueSchema.parse({
version: '1',