Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/subsystems/attachment.i18n.yaml # docs/subsystems/attachment.md # docs/subsystems/attachment.zh.md # docs/subsystems/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/runtime/package.json # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -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/client/connection/README.md
|
||||
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
|
||||
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0
|
||||
README.md: 07849f0728aee6076b15a8216ddcab08521994d6
|
||||
README.zh.md: a7996d0f7cc2948da82877c47f9acc805be2bba8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Central contract re-export point: every contract import inside
|
||||
// web-runtime goes through this single file.
|
||||
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client boundary.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels added for this.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
@@ -12,7 +12,7 @@ export type {
|
||||
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
@@ -23,9 +23,9 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
// transportError lives in the apiproxy api layer (beside RpcResult, its
|
||||
// subject); re-exported here so connection consumers keep one contract
|
||||
// entry point.
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
||||
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
||||
export interface ConnectionConfig {
|
||||
/** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
|
||||
backoffBaseMs?: number
|
||||
@@ -10,9 +10,9 @@ export interface ConnectionConfig {
|
||||
/** Upper bound for the backoff cap in ms. */
|
||||
backoffMaxMs?: number
|
||||
/** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
|
||||
* (audit C2) waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
|
||||
* generation proceeds as connected and the live-gap repair path (audit S3) covers stragglers. */
|
||||
* generation proceeds as connected and the live-gap repair path covers stragglers. */
|
||||
streamOpenTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
|
||||
/** Coarse connection state for the UI: 'connected' after each generation's handshake,
|
||||
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
|
||||
export type ConnectionState = 'connected' | 'reconnecting'
|
||||
|
||||
@@ -125,7 +125,7 @@ export class ConnectionController {
|
||||
})
|
||||
|
||||
try {
|
||||
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
|
||||
// Strict readiness handshake: describe proves unary reachability, onOpen
|
||||
// proves each physical stream is established before any frame —
|
||||
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
|
||||
@@ -31,7 +31,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -97,7 +97,7 @@ function sgr(code: number, body: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 65, authored to carry every feature
|
||||
* Terminal output sample for fixture turn 66, authored to carry every feature
|
||||
* the terminal card draws that turn 60's two prompt rows cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
@@ -142,7 +142,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grep result for the search sample (turn 66): matches grouped by
|
||||
* Structured grep result for the search sample (turn 67): matches grouped by
|
||||
* file, authored inline because the client-side fixture cannot import the tool
|
||||
* that produces the canonical value. `truncated` with a larger `total` than the
|
||||
* retained match count exercises the search card's capped indicator; the file
|
||||
@@ -192,7 +192,7 @@ const SEARCH_MATCHES_TEXT = [
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Structured glob result for the search sample (turn 67): a flat path list,
|
||||
* Structured glob result for the search sample (turn 68): a flat path list,
|
||||
* truncated with a larger `total` so the path card shows its capped indicator.
|
||||
*/
|
||||
const SEARCH_PATHS_FIXTURE = [
|
||||
@@ -437,19 +437,19 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
|
||||
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
|
||||
// the presenter reads to emit the two-hunk sample: the card draws one path
|
||||
// header, the first hunk, a `⋯` gap, then the second (the same-file
|
||||
// second-hunk arm turns 62/63 cannot reach).
|
||||
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
|
||||
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const turn = 65
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
|
||||
@@ -467,12 +467,12 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
@@ -497,7 +497,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
@@ -509,42 +509,42 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
|
||||
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
|
||||
// truncated). Both ride the keyed SearchRow registration under their own
|
||||
// names; the render-site fallback row is covered by the model derivation
|
||||
// tests, since every fixture search tool has a keyed row. Ordered before the
|
||||
// todo turn for the same standing-plan reason the bash turn is.
|
||||
toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
|
||||
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
|
||||
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
|
||||
// line numbers starting above 1 and a "showing N of M" note (the window is
|
||||
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
|
||||
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
|
||||
// The render-site fallback ROW SHAPE (a read call on the generic flattened
|
||||
// path) is covered by the turn 64 run_code read sub-dispatches, which
|
||||
// path) is covered by the turn 65 run_code read sub-dispatches, which
|
||||
// session.ts folds with resultView: null; the fallback-row + read-CARD
|
||||
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
|
||||
// this fixture. The read render intent is result-side only, so its pending
|
||||
// call stays a generic `kind: 'read'` card; presentResult carries the
|
||||
// structured window.
|
||||
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
|
||||
// Turns 69-70: the web render intent — a web_search whose result view carries
|
||||
// Turns 70-71: the web render intent — a web_search whose result view carries
|
||||
// structured sources plus an answer (the citation list, one source lacking a
|
||||
// title so its hostname labels the link, the capped indicator on), and a
|
||||
// web_fetch whose result view carries the fetched URL and its HTTP status.
|
||||
// Both keep a generic pending call view and add the `web` card only at
|
||||
// result time, which is the contract's result-only web shape. Named after
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 65 is: the standing plan retires at
|
||||
// the todo turn for the same reason turn 66 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
// Turn 71: user and assistant images share one durable fixture object.
|
||||
// The todo turn remains last so its standing projection stays visible.
|
||||
@@ -613,7 +613,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
case 'read':
|
||||
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
|
||||
case 'edit':
|
||||
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
|
||||
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
|
||||
// scattered hunks share one path header and the card draws the `⋯` gap.
|
||||
if (str(args.file_path) === 'src/config.ts') {
|
||||
return {
|
||||
@@ -1330,7 +1330,7 @@ export interface FixtureOptions {
|
||||
|
||||
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
|
||||
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
|
||||
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
|
||||
* piles up for the stream's lifetime). breakNow force-ends the stream without the
|
||||
* client's signal (timing hook: simulated connection loss). */
|
||||
class FxInbox<F> implements StreamConn<F> {
|
||||
private readonly inbox: RpcRequest<F>[] = []
|
||||
@@ -1393,7 +1393,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
||||
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
@@ -1708,7 +1708,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
|
||||
/** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
|
||||
let historyDelayMs = 0
|
||||
/** One-shot history failure (timing hook: the doomed in-flight request of the S4 reconnect scenario). */
|
||||
/** One-shot history failure (timing hook: a pre-disconnect history request already doomed when reconnect lands). */
|
||||
let failNextHistory = false
|
||||
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
|
||||
const streamBreakers = new Set<() => void>()
|
||||
@@ -1717,8 +1717,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
|
||||
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is
|
||||
// ideally timed. These let
|
||||
// browser acceptance runs create slow-history, lost-frame, and reconnect
|
||||
// windows a real host produces naturally.
|
||||
const timingHooks = {
|
||||
@@ -2039,7 +2039,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
@@ -2148,7 +2148,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
current: modelSelections.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
@@ -2157,14 +2157,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: (request) => {
|
||||
const selected: ModelTarget = {
|
||||
const selected: ModelSelection = {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
}
|
||||
modelTargets.set(request.payload.sessionId, selected)
|
||||
modelSelections.set(request.payload.sessionId, selected)
|
||||
return ok(request, { selected })
|
||||
},
|
||||
prompt: (request) => {
|
||||
@@ -2220,11 +2220,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== target.model) {
|
||||
const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== selection.model) {
|
||||
append(id, {
|
||||
type: 'request/context',
|
||||
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
|
||||
data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 },
|
||||
})
|
||||
}
|
||||
startReply(
|
||||
@@ -2234,9 +2234,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
? MARKDOWN_FIXTURE
|
||||
: userText === 'report model'
|
||||
? (() => {
|
||||
const target = modelTargets.get(id)
|
||||
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
||||
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
||||
const selection = modelSelections.get(id)
|
||||
return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}`
|
||||
+ (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`)
|
||||
})()
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
@@ -2291,6 +2291,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
@@ -2778,8 +2779,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
* Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
|
||||
* overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch
|
||||
* straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four
|
||||
* named full forms, and feeding the same tap as a real carrier. Delete when the fixture moves
|
||||
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
* named full forms, and feeding the same tap as a real carrier. TODO: delete when the fixture
|
||||
* moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api: ApiProxy
|
||||
@@ -2837,6 +2838,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
|
||||
@@ -20,7 +20,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
@@ -50,11 +50,11 @@ export class FakeApiClient implements IApiClient {
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelSelection: ModelSelection }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
@@ -63,8 +63,8 @@ export class FakeApiClient implements IApiClient {
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
onSelectModel: (payload: ModelSelection & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
@@ -107,7 +107,7 @@ export class FakeApiClient implements IApiClient {
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
selectModel: (payload: ModelSelection & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
@@ -129,6 +129,9 @@ export class FakeApiClient implements IApiClient {
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
|
||||
accepted: true as const,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
const catalog = await api.sessions.models(req({ sessionId }))
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
@@ -31,10 +31,10 @@
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
"path": "../../interaction/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
Reference in New Issue
Block a user