Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-2-configs
This commit is contained in:
@@ -100,6 +100,12 @@
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
# Shared projection registry: subagent catalog identity (mode/label) folds
|
||||
# through its registered units, so the `list_agents` surface below fails
|
||||
# loud without it; web layers reuse this same mount for list rows.
|
||||
- id: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
# Session telemetry, on for every dsh mode: mirrors every session-log
|
||||
# event (assistant/chunk projected to first-of-step) plus ops markers onto
|
||||
# OTLP/HTTP log records, streaming on the batch processor's cadence
|
||||
@@ -320,6 +326,8 @@
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
config:
|
||||
allowParallelInProgress: true
|
||||
|
||||
# Persisted same-session goals reach the model and the slash menu here; the
|
||||
# domain, driver, and `/goal` command are above.
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
|
||||
@@ -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/bundle/web-app/README.md
|
||||
README.md: a835497982e41c29c5dbc287c43b0515a61f77c9
|
||||
README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef
|
||||
README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b
|
||||
README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -45,9 +45,6 @@
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
- id: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
|
||||
@@ -474,11 +474,14 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip.
|
||||
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
const fixtureTodos = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
@@ -531,7 +534,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
|
||||
@@ -242,6 +242,10 @@ describe('createFixtureApi', () => {
|
||||
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
|
||||
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
|
||||
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
|
||||
// The sample is a parallel plan: this fixture chooses the parallel policy,
|
||||
// so the surfaces fed from here face more than one active item.
|
||||
const snapshot = events[todoAt] as { data: { todos: { status: string }[] } }
|
||||
expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
|
||||
@@ -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/ui-conversation/README.md
|
||||
README.md: bbd115eac0eb914914dc11e504639633c801abdd
|
||||
README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc
|
||||
README.md: a75f25d8669cd688795842a655106e0e27bb7173
|
||||
README.zh.md: f0d744c31020730857d210d75749b907dbffca08
|
||||
|
||||
@@ -34,11 +34,11 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl
|
||||
|
||||
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
|
||||
|
||||
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> completed · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority.
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
|
||||
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
|
||||
@@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
|
||||
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
- **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel has no durable grant control** — it supports allow-once and reject only.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -34,11 +34,11 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+<n>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow` 的 `summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加它自行计算的、以 `·` 连接的各状态计数(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
|
||||
@@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
|
||||
- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
// Finalized content (text) nodes append IconActions once streaming ends
|
||||
// (`time` is omitted for mid-turn narration); their branch action is enabled
|
||||
// only when the node is also the completed turn's transcript tail. Think /
|
||||
// tool-head-only nodes stay chrome-free.
|
||||
// Finalized content (text) nodes append IconActions once their turn ends
|
||||
// (`time` is omitted for mid-turn narration and while the turn still runs);
|
||||
// their branch action is enabled only when the node is also the completed
|
||||
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
@@ -25,7 +26,8 @@ export interface AssistantMarkdownProps {
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants). */
|
||||
* the parent withholds chrome (mid-turn content assistants and every node
|
||||
* of a turn that has not ended). */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms for the IconActions run-time label; omitted when the
|
||||
* turn's triggering input is outside the loaded window. */
|
||||
@@ -65,11 +67,6 @@ function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** True when the node has model-visible text content worth chrome under. */
|
||||
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -358,10 +358,11 @@ export function ChatView({
|
||||
[inbox],
|
||||
)
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
// Only the last content assistant of each completed turn owns IconActions;
|
||||
// mid-turn text and every node of a running turn omit `time`, so
|
||||
// AssistantMarkdown stays chrome-free until the answer settles.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
|
||||
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
|
||||
|
||||
@@ -631,8 +632,6 @@ export function ChatView({
|
||||
<MessageItem
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface MessageIconActionsProps {
|
||||
onBranch?: (() => void) | undefined
|
||||
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
|
||||
branchUnavailable?: boolean | undefined
|
||||
/** Additional branch visibility gate for transient message chrome; defaults to true. */
|
||||
showBranch?: boolean | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -41,7 +39,7 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
@@ -111,7 +109,7 @@ export function MessageIconActions({
|
||||
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showBranch && onBranch !== undefined && (
|
||||
{onBranch !== undefined && (
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
|
||||
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
|
||||
<button
|
||||
@@ -127,7 +125,7 @@ export function MessageIconActions({
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showBranch && onBranch !== undefined && branchUnavailable && (
|
||||
{onBranch !== undefined && branchUnavailable && (
|
||||
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// MessageItem: simple chat nodes — user and consumed-steering bubbles
|
||||
// (right-aligned, with clock + copy / branch IconActions; steering adds the
|
||||
// interjection caption that names it), pending steering (caption + copy only),
|
||||
// context injection, compaction marker, retry disclosure, and unknown-surface
|
||||
// JSON rows.
|
||||
// (right-aligned, with clock + copy IconActions; steering adds the
|
||||
// interjection caption that names it; branch lives only under assistant
|
||||
// answers), pending steering (caption + copy only), context injection,
|
||||
// compaction marker, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -27,10 +27,6 @@ export interface MessageItemProps {
|
||||
| TurnErrorNode
|
||||
| UnknownSurfaceNode
|
||||
retryActive?: boolean
|
||||
/** Fork through this message's completed turn when eligible. */
|
||||
onFork?: (seq: number) => void
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -217,7 +213,6 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
clock="start"
|
||||
showBranch={false}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
@@ -227,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, forkUnavailable = false, t,
|
||||
node, retryActive = false, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
@@ -243,8 +238,6 @@ export const MessageItem = memo(function MessageItem({
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -89,6 +89,20 @@
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
|
||||
whole value is that it survives a narrow row (the todo row's parallel-active
|
||||
`+n`). Repeats .summary's type because it sits beside that text, and its
|
||||
`nowrap` too: `flex: none` stops the box shrinking but not the text wrapping,
|
||||
which would break the one-line row in the narrow case the slot exists for. */
|
||||
.summarySuffix {
|
||||
flex: none;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -46,6 +46,14 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Trailing summary fragment rendered outside the ellipsized summary text, so
|
||||
* a narrow row clips the summary before this. For a fragment whose whole
|
||||
* value is surviving that clip — the todo row's parallel-active count.
|
||||
* null/absent = the summary is the whole collapsed content. Dropped on an
|
||||
* error row, whose collapsed summary is the failure line instead.
|
||||
*/
|
||||
summarySuffix?: string | null | undefined
|
||||
/** Expanded-body input text; null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text for the expanded Output section; null/absent = no output section. */
|
||||
@@ -139,6 +147,7 @@ export function ToolRow({
|
||||
icon,
|
||||
title,
|
||||
summary,
|
||||
summarySuffix,
|
||||
body,
|
||||
output,
|
||||
errorSummary,
|
||||
@@ -173,6 +182,9 @@ export function ToolRow({
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line replaces the summary wholesale, so a suffix derived from
|
||||
// the call args has nothing left to sit beside.
|
||||
const suffix = failureLine === null ? summarySuffix ?? null : null
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const isThink = variant === 'think'
|
||||
@@ -249,6 +261,7 @@ export function ToolRow({
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
{suffix !== null && <span className={css.summarySuffix}>{suffix}</span>}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -17,8 +17,14 @@ export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
|
||||
/** True when the node has model-visible text content worth IconActions chrome. */
|
||||
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
/**
|
||||
* True when the node has model-visible text content worth IconActions chrome.
|
||||
* Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting
|
||||
* cannot diverge.
|
||||
* @param blocks - assistant blocks of one finalized node.
|
||||
* @returns Whether any text block carries non-blank content.
|
||||
*/
|
||||
export function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
@@ -34,14 +40,20 @@ function rendersNothing(node: ConversationNode): boolean {
|
||||
|
||||
/**
|
||||
* Seq set of assistants that own IconActions: the last content-text assistant
|
||||
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
|
||||
* of each *completed* turn. A turn without a `turn/end` in the window is still
|
||||
* producing steps, so its latest narration is not the settled answer and owns
|
||||
* nothing; mid-turn narration of a completed turn stays chrome-free too.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
|
||||
*/
|
||||
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
|
||||
export function assistantActionsSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const lastByTurn = new Map<number, number>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
|
||||
if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue
|
||||
lastByTurn.set(node.turn, node.seq)
|
||||
}
|
||||
return new Set(lastByTurn.values())
|
||||
@@ -63,15 +75,18 @@ export function runningTurnStartTime(
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of message rows that may fork: the last transcript node of a
|
||||
* completed turn, when that node owns message chrome. A later tool, reasoning,
|
||||
* error, or other transcript node leaves the earlier message's branch action
|
||||
* unavailable because the Host would include the whole turn.
|
||||
* Seq set of assistant answers that may fork: the completed turn's transcript
|
||||
* tail, when that tail is the turn's own content-text assistant. A later tool,
|
||||
* reasoning, error, or other transcript node leaves the answer's branch action
|
||||
* unavailable because the Host would include the whole turn. User and steering
|
||||
* bubbles carry no branch action at all: a fork at their seq cuts at the same
|
||||
* `turn/end` as the answer's, so the affordance lives only under the settled
|
||||
* answer.
|
||||
* @param nodes - snapshot nodes in event order.
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Message seq values whose visible position matches the fork boundary.
|
||||
* @returns Assistant seq values whose visible position matches the fork boundary.
|
||||
*/
|
||||
export function messageBranchSeqs(
|
||||
export function assistantBranchSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
@@ -86,8 +101,7 @@ export function messageBranchSeqs(
|
||||
tail = candidate
|
||||
nodeIndex++
|
||||
}
|
||||
if (tail?.kind === 'user' || tail?.kind === 'steering'
|
||||
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
|
||||
if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) {
|
||||
result.add(tail.seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,14 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* The column scrolls on ONE axis. Stating `hidden` rather than leaving the
|
||||
initial `visible` is what removes the horizontal bar: a box that scrolls in
|
||||
one axis computes `visible` to `auto` in the other, so any bleed becomes
|
||||
user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero
|
||||
box), which put a horizontal scrollbar under every center column narrower
|
||||
than the glow. Clipping is unchanged — `overflow-y: auto` already made this
|
||||
a scroll container that clips both axes, so this only takes away the bar. */
|
||||
overflow-x: hidden;
|
||||
/* Reserved unconditionally: the composer seat rides this box's content box in
|
||||
Chat and its padding box under a view's composer overlay, so an `auto`
|
||||
gutter moves the input card sideways by the bar's width whenever the two
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pure plan derivation for the todo_write row's one-line summary. Several items
|
||||
* may be `in_progress` at once — parallel work runs concurrent tasks, so a
|
||||
* summary built from one active item would silently drop the rest. The plan
|
||||
* strip header derives its own counts inline and shares nothing with this, so
|
||||
* this stays inside the toolviews domain rather than in `contract/` (the
|
||||
* inter-domain face).
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* One list item as the row sees it: unvalidated model JSON parsed from a call's
|
||||
* args, so any field may be missing or mistyped.
|
||||
*/
|
||||
export interface PlanItemLike {
|
||||
content?: unknown
|
||||
status?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts plus the two halves of the summary, deliberately NOT pre-joined: the
|
||||
* row ellipsizes its summary text, and a count concatenated onto the end of the
|
||||
* task name is the first thing a narrow row clips — exactly when it carries
|
||||
* information. The row renders `activeExtra` in its own non-shrinking span
|
||||
* beside the truncatable text.
|
||||
*/
|
||||
export interface PlanSummary {
|
||||
done: number
|
||||
total: number
|
||||
/** First `in_progress` content, or null when that first item is unusable. */
|
||||
activeContent: string | null
|
||||
/** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */
|
||||
activeExtra: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the counts and the active summary from a whole-list snapshot. It names
|
||||
* the first `in_progress` item and counts the remaining active ones, so a
|
||||
* parallel plan reports how many tasks are running rather than naming one and
|
||||
* hiding the others. `activeContent` is null when nothing is in progress, or
|
||||
* when the first active item's content is missing, mistyped, or blank once
|
||||
* trimmed — the tool's own rule for usable content, applied here because a
|
||||
* rejected call keeps its args verbatim. The row then renders the counts alone
|
||||
* rather than falling back to the generic tool summary: the counts are already
|
||||
* known to be good, and the active-item clause is the only part an unusable
|
||||
* name costs.
|
||||
* @param todos - the whole list, in model order.
|
||||
* @returns the done/total counts and the two summary halves.
|
||||
*/
|
||||
export function planSummary(todos: readonly PlanItemLike[]): PlanSummary {
|
||||
const active = todos.filter(t => t.status === 'in_progress')
|
||||
const first = active[0]?.content
|
||||
const named = typeof first === 'string' && first.trim() !== ''
|
||||
return {
|
||||
done: todos.filter(t => t.status === 'completed').length,
|
||||
total: todos.length,
|
||||
activeContent: named ? first : null,
|
||||
activeExtra: named ? active.length - 1 : 0,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
|
||||
// summary of the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line until expanded.
|
||||
// summary of the written list (counts + active items) from the call args, with
|
||||
// the parallel-active count riding ToolRow's non-shrinking summary suffix so a
|
||||
// narrow row never clips it; the durable list itself renders in the TodoPanel
|
||||
// above the composer, so the row stays one line until expanded.
|
||||
|
||||
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -13,18 +14,26 @@ import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
import { planSummary, type PlanItemLike } from './plan-summary.ts'
|
||||
|
||||
/** Todo row props: the toolview runtime share plus the standard locale seat. */
|
||||
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
interface TodoWriteItem { content?: unknown; status?: unknown }
|
||||
|
||||
function isItem(value: unknown): value is TodoWriteItem {
|
||||
function isItem(value: unknown): value is PlanItemLike {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
/**
|
||||
* The row's summary split at the ellipsis boundary: `text` truncates, `extra`
|
||||
* is the parallel-active count that must not, so a narrow row never clips the
|
||||
* one part that says several tasks are running.
|
||||
*/
|
||||
interface RowSummary {
|
||||
text: string
|
||||
extra: number
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(argsRaw)
|
||||
@@ -37,12 +46,12 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const todos = (parsed as { todos?: unknown }).todos
|
||||
if (!Array.isArray(todos) || !todos.every(isItem)) return null
|
||||
const done = todos.filter(item => item.status === 'completed').length
|
||||
const active = todos.find(item => item.status === 'in_progress')
|
||||
const head = t('todo.completed', { done, total: todos.length })
|
||||
return typeof active?.content === 'string' && active.content !== ''
|
||||
? `${head} · ${active.content}`
|
||||
: head
|
||||
const { done, total, activeContent, activeExtra } = planSummary(todos)
|
||||
const head = t('todo.completed', { done, total })
|
||||
return {
|
||||
text: activeContent === null ? head : `${head} · ${activeContent}`,
|
||||
extra: activeExtra,
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (the whole row toggles the call's Input/Output
|
||||
@@ -52,7 +61,7 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw, t) ?? model.summary
|
||||
const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 }
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
@@ -60,7 +69,8 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
toolName={toolName}
|
||||
icon={<IconChecklistOutline14 />}
|
||||
title={t('todo.rowTitle')}
|
||||
summary={summary}
|
||||
summary={summary.text}
|
||||
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
|
||||
@@ -36,7 +36,7 @@ afterEach(() => {
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -45,24 +45,20 @@ describe('MessageItem arms', () => {
|
||||
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
@@ -87,30 +83,6 @@ describe('MessageItem arms', () => {
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'open turn' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('user copy never claims success when the host rejects the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -212,19 +184,17 @@ describe('MessageItem arms', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
|
||||
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const fork = vi.fn()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
} as never}
|
||||
onFork={fork}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
@@ -232,8 +202,7 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('steer!')
|
||||
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(fork).toHaveBeenCalledWith(2)
|
||||
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
|
||||
@@ -1002,6 +971,31 @@ describe('small branch tails', () => {
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
|
||||
streaming={false}
|
||||
time={1_000}
|
||||
seq={1}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||||
// output accounting) — any billed input makes it a real 0%.
|
||||
|
||||
@@ -301,6 +301,20 @@ describe('ToolRow', () => {
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
|
||||
const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
|
||||
const summary = view.getByText('List files')
|
||||
const suffix = view.getByText('+2')
|
||||
// Separate spans: .summary truncates, the suffix must not travel inside it.
|
||||
expect(summary.contains(suffix)).toBe(false)
|
||||
view.unmount()
|
||||
// The failure line replaces the summary wholesale, so the suffix goes with it.
|
||||
const failed = render(
|
||||
<ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
|
||||
)
|
||||
expect(failed.queryByText('+2')).toBeNull()
|
||||
})
|
||||
|
||||
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
|
||||
@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -225,12 +225,12 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
|
||||
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
|
||||
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
|
||||
const thinkOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'planning' }],
|
||||
}
|
||||
const seqs = assistantActionsSeqs([
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'looking', 1),
|
||||
thinkOnly,
|
||||
@@ -238,8 +238,11 @@ describe('chat-flow derivation', () => {
|
||||
assistant(5, 'done', 1),
|
||||
user(6, 'again'),
|
||||
assistant(7, 'second turn', 2),
|
||||
])
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
]
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
// Turn 2 is still producing steps: its latest narration owns nothing, and
|
||||
// the settled turn 1 keeps its seat.
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
@@ -261,7 +264,7 @@ describe('chat-flow derivation', () => {
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
||||
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
@@ -276,8 +279,8 @@ describe('chat-flow derivation', () => {
|
||||
user(10, 'user-only tail'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7, 10, 13])
|
||||
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,21 +404,24 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByText('插话')).toHaveLength(1)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
// Only the durable steering bubble: the turn is still running, so its
|
||||
// assistant narration owns no footer yet, and a steering bubble never
|
||||
// carries a branch action.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
|
||||
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
|
||||
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
|
||||
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(unavailable)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
// The completed turn's transcript tail is the steering bubble, not the
|
||||
// narration, so the assistant's branch action stays unavailable and the
|
||||
// steering bubble still offers none.
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
|
||||
fireEvent.click(branchButtons[1]!)
|
||||
expect(h.forkAt).toHaveBeenCalledWith(2)
|
||||
expect(branchButtons).toHaveLength(1)
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(branchButtons[0]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
|
||||
@@ -518,11 +524,35 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 4], [2, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Every message footer keeps branch visible; only completed assistant tails enable it.
|
||||
// Branch renders only under assistant answers; user bubbles keep copy alone.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(4)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('withholds assistant IconActions while the turn is still running', () => {
|
||||
const h = makeHarness({
|
||||
running: true,
|
||||
runningCalls: [runningCall('a')],
|
||||
nodes: [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'previous answer', 1),
|
||||
user(4, 'second'),
|
||||
assistant(5, 'mid-turn text', 2),
|
||||
],
|
||||
// Boundary seqs follow the log: a turn/end is strictly after its own nodes.
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// 2 user + the settled turn-1 tail, which keeps its seat while a later
|
||||
// turn runs; turn 2's narration stays chrome-free while its tool runs, so
|
||||
// the footer never appears and then moves.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
|
||||
expect(view.getByText('mid-turn text')).toBeTruthy()
|
||||
// turn/end lands: the same node becomes the settled answer and takes the seat.
|
||||
act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) })
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('the actions-owning assistant footer shows the turn run time', () => {
|
||||
@@ -606,11 +636,11 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The user bubble offers no branch; the settled answer's is live.
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[2]])
|
||||
})
|
||||
|
||||
@@ -626,10 +656,9 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, shared ToolRow
|
||||
* state dots and leading expansion).
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
|
||||
* including several `in_progress` at once, collapse), its TodoDock adapter
|
||||
* (selects the plan off the session snapshot and follows changes), the row's
|
||||
* plan summary (counts plus the two halves of the active summary — the named
|
||||
* task and the `+N` count that parallel work adds, kept apart so the row never
|
||||
* ellipsizes the count away), and the todo_write toolview row (progress summary
|
||||
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
|
||||
* leading expansion).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
@@ -32,6 +36,49 @@ const LIST: TodoItem[] = [
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
/** A parallel plan: three tasks running at once (concurrent subagents). */
|
||||
const PARALLEL: TodoItem[] = [
|
||||
{ content: '搭骨架', status: 'completed' },
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '读源码', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('planSummary', () => {
|
||||
it('counts done/total and names the single active item with no extra count', () => {
|
||||
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('reports the extra active count separately when several items are in progress', () => {
|
||||
// Parallel work marks several: naming one and hiding the rest would lose
|
||||
// them, and the count stays unjoined so the row cannot ellipsize it.
|
||||
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
|
||||
})
|
||||
|
||||
it('has no hint when nothing is in progress', () => {
|
||||
expect(planSummary([{ content: '都完了', status: 'completed' }]))
|
||||
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('has no hint when the first active item carries no usable content (model JSON)', () => {
|
||||
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
|
||||
// yields no hint — and no orphan count, even with a second active item to
|
||||
// count. Whitespace-only is the tool's own rejection rule (trimmed
|
||||
// non-empty), and a rejected call keeps its args verbatim.
|
||||
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('is empty-safe', () => {
|
||||
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
@@ -80,6 +127,18 @@ describe('TodoPanel', () => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks every parallel active item, and counts them all in the header', () => {
|
||||
render(<TodoPanel todos={PARALLEL} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
// The old unconditional cap made this list unreachable: three items carry
|
||||
// the in-progress glyph at once, and the header counts all three.
|
||||
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
|
||||
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
|
||||
expect(screen.getByText('跑后台构建')).toBeTruthy()
|
||||
expect(screen.getByText('读源码')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
@@ -145,12 +204,30 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the extra active count outside the ellipsized summary text', () => {
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
|
||||
const text = screen.getByText('1/5 已完成 · 写组件')
|
||||
const extra = screen.getByText('+2')
|
||||
// Separate spans: .summary truncates, the count must not travel inside it.
|
||||
expect(text.contains(extra)).toBe(false)
|
||||
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
|
||||
})
|
||||
|
||||
it('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
|
||||
// planSummary yields activeContent null here, but the counts are known good,
|
||||
// so the row drops only the active clause — `?? model.summary` never runs.
|
||||
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
|
||||
expect(screen.getByText('1/2 已完成')).toBeTruthy()
|
||||
expect(container.textContent).not.toContain('+')
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
|
||||
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
|
||||
* exist but not whether a narrow row still fits on one line; these read the
|
||||
* declarations the layout depends on.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
|
||||
/** Declarations only: the sheet's prose names the properties it explains. */
|
||||
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
function declarations(selector: string): string[] {
|
||||
// Anchored at a rule boundary: an unanchored match would silently read a
|
||||
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
|
||||
// if one ever lands above the base rule.
|
||||
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
|
||||
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
|
||||
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('ToolRow.module.css summary line', () => {
|
||||
it('keeps the summary suffix on one line and unshrunk', () => {
|
||||
// `flex: none` stops the box shrinking, not the text wrapping: without
|
||||
// `nowrap`, a row too narrow for title + separator + suffix wraps the `+n`
|
||||
// onto a second line — the exact case the slot exists to survive.
|
||||
expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([
|
||||
'flex: none',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
})
|
||||
|
||||
it('leaves the truncation to the summary text alone', () => {
|
||||
// The suffix must never ellipsize: a clipped count reads as a smaller
|
||||
// number rather than as missing information.
|
||||
expect(declarations('.summary')).toEqual(expect.arrayContaining([
|
||||
'overflow: hidden',
|
||||
'text-overflow: ellipsis',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis']))
|
||||
})
|
||||
})
|
||||
@@ -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/ui-models/README.md
|
||||
README.md: b55914197e472edec8a8b6d4d3e02036d1697728
|
||||
README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec
|
||||
README.md: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876
|
||||
README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4
|
||||
|
||||
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
@@ -29,7 +29,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
@@ -29,7 +29,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.savedNotice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
@@ -65,6 +72,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
@@ -72,6 +86,23 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.credentialDot {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.credentialDotConfigured {
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.credentialDotMissing {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Models settings section: the provider rows joined from the configurable
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time. A whole-section provider without a configured key (the
|
||||
* unconfigured DeepSeek posture) renders as its open setup card instead of a
|
||||
* row; the add flow is a card carrying the dormant-provider select. Every
|
||||
* mutation writes through the wire, while a provider removal first requires
|
||||
* card at a time. Rows expose only confirmed API-key state through accessible
|
||||
* solid configured or missing dots. A whole-section provider without a
|
||||
* configured key (the unconfigured DeepSeek posture) renders as its open setup
|
||||
* card instead of a row; the add flow is a card carrying the dormant-provider
|
||||
* select. Every mutation writes through the wire, while a provider removal first requires
|
||||
* confirmation; the page re-renders from pushed invalidations or the
|
||||
* post-apply reload.
|
||||
*/
|
||||
@@ -15,9 +16,9 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { messageOf, protocolChoices } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -39,42 +40,73 @@ export interface ModelsSectionInjected {
|
||||
*/
|
||||
export type ModelsSectionProps = Partial<ModelsSectionInjected>
|
||||
|
||||
/** The editor target: an existing row or a dormant directory entry. */
|
||||
interface EditorTarget {
|
||||
/** Provider identity shared by row actions and confirmation copy. */
|
||||
export interface ProviderIdentity {
|
||||
/** Stable provider route id. */
|
||||
provider: string
|
||||
/** Human-facing provider name. */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
/** One existing row or dormant directory entry addressed by an editor action. */
|
||||
interface EditorTarget extends ProviderIdentity {
|
||||
settingsNs: string
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
interface ProviderEditorRenderProps extends Pick<
|
||||
ProviderEditorProps,
|
||||
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
|
||||
> {
|
||||
target: EditorTarget
|
||||
}
|
||||
|
||||
/** Render an editor for either the setup posture or an expanded provider row. */
|
||||
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
|
||||
return (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile by unsetting its path in the stored
|
||||
* user section, then reload. The removal names the profile rather than
|
||||
* rebuilding the section: this page only ever holds the redacted descriptor,
|
||||
* so a rebuilt section would drop every literal secret stored elsewhere in
|
||||
* the namespace along with the profile being removed.
|
||||
* @param api - settings wire face.
|
||||
* Remove one user-added provider and its page-managed credential. Credential
|
||||
* removal comes first so a second-step failure leaves the provider row visible
|
||||
* and the whole operation safely retryable; both unsets are idempotent.
|
||||
* The settings removal names the profile rather than rebuilding its redacted
|
||||
* namespace, which would drop literal secrets stored elsewhere.
|
||||
* @param api - settings and credential wire faces.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address.
|
||||
* @param target - the provider's settings address and optional managed credential.
|
||||
* @returns the failure message, or undefined once the write and reload landed.
|
||||
*/
|
||||
export async function removeProviderProfile(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.mutate({
|
||||
if (target.credentialRef !== undefined) {
|
||||
const credential = await api.credentials.unset({ ref: target.credentialRef })
|
||||
if (!credential.result.ok) return credential.result.error.message
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: target.settingsNs,
|
||||
ops: [{ op: 'unset', path: [...target.settingsPath] }],
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able
|
||||
// to say so instead of the row silently staying put.
|
||||
// to retry the idempotent operation instead of the row silently staying.
|
||||
return messageOf(error)
|
||||
}
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
await controller.load()
|
||||
return undefined
|
||||
}
|
||||
@@ -93,14 +125,33 @@ export function needsSetup(row: ProviderRow): boolean {
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
const managedRef = deriveKeyRef(row.entry.provider)
|
||||
const credentialRef = row.apiKeyEnv === managedRef
|
||||
&& row.credential?.configured === true
|
||||
&& row.credential.writable
|
||||
? managedRef
|
||||
: undefined
|
||||
return {
|
||||
provider: row.entry.provider,
|
||||
displayName: row.entry.displayName,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable visible and accessible identity for one provider target. */
|
||||
export function providerTargetLabel(target: ProviderIdentity): string {
|
||||
return target.provider === target.displayName
|
||||
? target.provider
|
||||
: `${target.displayName} (${target.provider})`
|
||||
}
|
||||
|
||||
/** Replace the one provider placeholder in localized destructive-action copy. */
|
||||
export function providerCopy(template: string, target: ProviderIdentity): string {
|
||||
return template.replace('{provider}', () => providerTargetLabel(target))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Models section content column.
|
||||
* @param props - slot-delivered injected dependencies.
|
||||
@@ -119,28 +170,35 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean): void => {
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
}
|
||||
}
|
||||
|
||||
const closeDelete = (): void => {
|
||||
if (deleting) return
|
||||
setDeleteTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
}
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
|
||||
if (deleteTarget === undefined || deleting) return
|
||||
setDeleting(true)
|
||||
setDeleteFailure(undefined)
|
||||
void removeProviderProfile(api, controller, deleteTarget)
|
||||
.then((failure) => {
|
||||
if (failure !== undefined) {
|
||||
controller.fail(failure)
|
||||
setDeleteFailure(failure)
|
||||
return
|
||||
}
|
||||
setDeleteTarget(undefined)
|
||||
@@ -176,6 +234,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{savedTarget === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
||||
{providerCopy(t('savedProvider'), savedTarget)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles['rows']}>
|
||||
{configured.map((row) => {
|
||||
const target = targetOf(row)
|
||||
@@ -187,29 +252,54 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
// setup card IS its presence on the page.
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['setupCard']}>
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
{renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
|
||||
const credentialMissing = !credentialConfigured
|
||||
&& row.apiKeyEnv !== undefined
|
||||
&& row.credential?.configured === false
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['rowCard']}>
|
||||
<div className={styles['rowHead']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
<span className={styles['rowIdentity']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
{credentialConfigured
|
||||
? (
|
||||
<span
|
||||
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
|
||||
role="img"
|
||||
aria-label={t('credentialConfigured')}
|
||||
title={t('credentialConfigured')}
|
||||
/>
|
||||
)
|
||||
: credentialMissing
|
||||
? (
|
||||
<span
|
||||
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
|
||||
role="img"
|
||||
aria-label={t('credentialMissing')}
|
||||
title={t('credentialMissing')}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
<span className={styles['rowActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
aria-label={providerCopy(t('editProvider'), target)}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
// One card at a time: leaving `declaring` set would show
|
||||
// the create card beside this editor, and closing either
|
||||
// one discards the other's draft.
|
||||
@@ -225,8 +315,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
aria-label={providerCopy(t('removeProvider'), target)}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { setDeleteTarget(target) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
setDeleteTarget(target)
|
||||
}}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
@@ -235,18 +330,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
</span>
|
||||
</div>
|
||||
{open
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
@@ -284,7 +375,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -299,7 +390,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -317,6 +411,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setDeclaring(false)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
@@ -330,7 +425,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={protocols.length === 0 || !state.writable}
|
||||
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('customAdd')}
|
||||
@@ -341,9 +441,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
title={t('deleteTitle')}
|
||||
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
description={deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(
|
||||
deleteTarget.credentialRef === undefined
|
||||
? t('deleteDescription')
|
||||
: t('deleteDescriptionWithCredential'),
|
||||
deleteTarget,
|
||||
)}
|
||||
className={styles['deleteDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
@@ -356,11 +463,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{deleting ? t('deleting') : t('deleteConfirm')}
|
||||
{deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* field is a single write-only **API key** input (the page never asks for an
|
||||
* environment-variable name — a typed key stores through `credentials.set`
|
||||
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
@@ -133,10 +135,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
// The revision this card opened at. A write carrying it is refused if
|
||||
// anything else — another tab, an external edit of settings.yaml — moved the
|
||||
// namespace meanwhile, instead of silently overwriting that change.
|
||||
const [openedAt] = useState(() => namespace.revision)
|
||||
// A settings success advances both retry baselines immediately. Keeping the
|
||||
// derived fields in the draft prevents a pushed namespace refresh from
|
||||
// turning them into deletions when the following credential write is retried.
|
||||
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
|
||||
() => getPath(namespace.user, settingsPath),
|
||||
)
|
||||
const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
@@ -194,11 +199,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
// The pi-ai profile must name the reference the key stores under, so a
|
||||
// dormant add (or a legacy profile without one) records the derivation.
|
||||
const normalizedKey = keyDraft.trim()
|
||||
// A pi-ai profile names the conventional reference only when this page is
|
||||
// about to store a key. Otherwise the provider keeps its native auth path.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
@@ -217,17 +222,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
const ops = pathOps(settingsPath, original, next)
|
||||
const materializesNativeProfile = layout === 'pi-ai'
|
||||
&& fallback === undefined
|
||||
&& committedOriginal === undefined
|
||||
&& Object.keys(next).length === 0
|
||||
const ops: SettingsPathOpView[] = materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision })
|
||||
if (!response.result.ok) {
|
||||
return response.result.error.code === 'settings-conflict'
|
||||
? t('conflict')
|
||||
: response.result.error.message
|
||||
}
|
||||
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
|
||||
setExpectedRevision(response.result.value.revision)
|
||||
setDraft(next)
|
||||
}
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (normalizedKey.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
@@ -286,6 +300,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
const keyPlaceholder = keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
const catalogProps = {
|
||||
models,
|
||||
@@ -306,9 +325,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
|
||||
@@ -6,23 +6,30 @@ export const en = {
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
edit: 'Edit',
|
||||
editProvider: 'Edit {provider}',
|
||||
remove: 'Delete',
|
||||
deleteTitle: 'Delete model provider?',
|
||||
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
|
||||
deleteConfirm: 'Delete provider',
|
||||
deleting: 'Deleting provider…',
|
||||
removeProvider: 'Delete {provider}',
|
||||
deleteTitle: 'Delete {provider}?',
|
||||
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
|
||||
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
|
||||
deleteConfirm: 'Delete {provider}',
|
||||
deleting: 'Deleting {provider}…',
|
||||
add: 'Add provider',
|
||||
provider: 'Provider',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
applying: 'Applying…',
|
||||
savedProvider: 'Saved {provider}.',
|
||||
credentialConfigured: 'API key configured',
|
||||
credentialMissing: 'API key missing',
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
|
||||
retry: 'Retry',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication',
|
||||
keyStored: 'Configured — enter a new value to replace',
|
||||
keyEnvLocked: 'Provided by the launch environment (read-only)',
|
||||
customized: 'Customized settings',
|
||||
@@ -90,23 +97,30 @@ export const zh: typeof en = {
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
deleteTitle: '删除模型提供方?',
|
||||
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
|
||||
deleteConfirm: '删除提供方',
|
||||
deleting: '正在删除提供方…',
|
||||
removeProvider: '删除 {provider}',
|
||||
deleteTitle: '删除 {provider}?',
|
||||
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
|
||||
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
|
||||
deleteConfirm: '删除 {provider}',
|
||||
deleting: '正在删除 {provider}…',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
applying: '保存中…',
|
||||
savedProvider: '已保存 {provider}。',
|
||||
credentialConfigured: 'API 密钥已配置',
|
||||
credentialMissing: 'API 密钥缺失',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证',
|
||||
keyStored: '已配置——输入新值可替换',
|
||||
keyEnvLocked: '由启动环境提供(只读)',
|
||||
customized: '自定义设置',
|
||||
|
||||
@@ -125,18 +125,6 @@ export class ModelsSettingsStore {
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
|
||||
|
||||
/**
|
||||
* Surface a failure from an operation the page ran outside {@link load} —
|
||||
* a row removal — on the same banner a load failure uses.
|
||||
* @param message - the failure text to show.
|
||||
*/
|
||||
fail(message: string): void {
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('ui-models apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
expect(injected.t('nav')).toBe('模型')
|
||||
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(injected.t('deleteTitle')).toBe('删除 {provider}?')
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
@@ -80,10 +80,10 @@ describe('ui-models apply', () => {
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
|
||||
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
|
||||
expect(injected().t('deleteTitle')).toBe('Delete {provider}?')
|
||||
b.locale.setLocale('zh')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
|
||||
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(injected().t('deleteTitle')).toBe('删除 {provider}?')
|
||||
})
|
||||
|
||||
it('locale change while the slot is undeclared stays a no-op', async () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import {
|
||||
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
|
||||
} from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import {
|
||||
@@ -18,6 +20,8 @@ import { en } from '../src/client/locales.ts'
|
||||
afterEach(cleanup)
|
||||
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
|
||||
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
|
||||
|
||||
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
||||
function expandRow(position: number): void {
|
||||
@@ -136,11 +140,13 @@ function scriptedFace(overrides: {
|
||||
replace?: ReturnType<typeof vi.fn>
|
||||
mutate?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
unset?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
providers: vi.fn(() => Promise.resolve(ok({
|
||||
@@ -170,16 +176,16 @@ function scriptedFace(overrides: {
|
||||
}])),
|
||||
}))),
|
||||
set,
|
||||
unset: vi.fn(() => Promise.resolve(ok({}))),
|
||||
unset,
|
||||
},
|
||||
}
|
||||
return { face, update, replace, mutate, set }
|
||||
return { face, update, replace, mutate, set, unset }
|
||||
}
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, mutate, set } = scriptedFace(overrides)
|
||||
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
@@ -189,7 +195,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
t,
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, mutate, set, controller }
|
||||
return { view, face, update, replace, mutate, set, unset, controller }
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
@@ -207,9 +213,36 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('Active')).toBeNull()
|
||||
expect(screen.queryByText('Inactive')).toBeNull()
|
||||
const configured = screen.getByRole('img', { name: en.credentialConfigured })
|
||||
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
|
||||
expect(configured.className).toContain('credentialDotConfigured')
|
||||
expect(configured.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
|
||||
const { face } = scriptedFace()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
api={face as never}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
expect(missing.getAttribute('title')).toBe(en.credentialMissing)
|
||||
expect(missing.className).toContain('credentialDotMissing')
|
||||
expect(missing.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull()
|
||||
expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
@@ -254,6 +287,13 @@ describe('ModelsSection', () => {
|
||||
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
|
||||
})
|
||||
|
||||
it('uses one stable provider identity in action copy', () => {
|
||||
const target = { provider: 'deepseek-official', displayName: 'DeepSeek' }
|
||||
expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)')
|
||||
expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?')
|
||||
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
|
||||
})
|
||||
|
||||
it('names only the fields the card can see, so an unseen secret survives', () => {
|
||||
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
||||
// side, so no op mentions it and the seam leaves it alone.
|
||||
@@ -268,11 +308,16 @@ describe('ModelsSection', () => {
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
const { set, update, face } = await mountSection()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.change(key, { target: { value: ' sk-live ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
expect((await screen.findByRole('status')).textContent).toBe(
|
||||
providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
|
||||
)
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
@@ -777,6 +822,7 @@ describe('ModelsSection', () => {
|
||||
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
@@ -788,6 +834,59 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
|
||||
})
|
||||
|
||||
it('keeps pi-ai provider-native authentication when no key is entered', async () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries only the credential after refreshed settings already committed', async () => {
|
||||
const committed = wireNamespaces()[2]!
|
||||
const afterSettings: SettingsNamespaceView = {
|
||||
...committed,
|
||||
value: { providers: {
|
||||
...(committed.value as { providers: object }).providers,
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
} },
|
||||
user: { providers: {
|
||||
...(committed.user as { providers: object }).providers,
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
} },
|
||||
revision: 1,
|
||||
}
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(afterSettings)))
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { face, controller } = await mountSection({ mutate, set })
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await screen.findByText('credential store unavailable')
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
face.settings.describe.mockResolvedValue(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
|
||||
}))
|
||||
await act(async () => { await controller.load() })
|
||||
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
|
||||
})
|
||||
|
||||
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
@@ -876,6 +975,7 @@ describe('ModelsSection', () => {
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/shadowed by the read-only environment/)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('locks the key input when the launch environment provides the credential', async () => {
|
||||
@@ -898,34 +998,37 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('requires confirmation before removing a user-added provider', async () => {
|
||||
const { replace, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
expect(dialog.textContent).toContain(en.deleteDescription)
|
||||
const { replace, mutate, unset } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
|
||||
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(unset).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
|
||||
.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
|
||||
.getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
|
||||
await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -939,20 +1042,22 @@ describe('ModelsSection', () => {
|
||||
resolveRemoval = resolve
|
||||
}))
|
||||
await mountSection({ mutate })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
|
||||
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
|
||||
expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
|
||||
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
|
||||
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
@@ -1057,15 +1162,58 @@ describe('ModelsSection', () => {
|
||||
expect(controller.store.getSnapshot().rows).toBe(before)
|
||||
})
|
||||
|
||||
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
|
||||
// The whole click path: the row's Remove button, the transport rejecting
|
||||
// with a non-Error value, and the store surfacing it where a load failure
|
||||
// would appear — rather than the row silently staying put.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
await screen.findByText(`${en.loadFailed}: the host refused`)
|
||||
it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(fail('the host refused'))
|
||||
.mockResolvedValueOnce(ok(wireNamespaces()[2]!))
|
||||
const { unset } = await mountSection({ mutate })
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) })
|
||||
fireEvent.click(confirm)
|
||||
await within(dialog).findByText('the host refused')
|
||||
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
|
||||
expect(unset).toHaveBeenCalledOnce()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(confirm)
|
||||
await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('retains credentials that are not identified as page-managed', async () => {
|
||||
const { unset, mutate } = await mountSection()
|
||||
const target = { provider: 'zombie', displayName: 'zombie' }
|
||||
fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) }))
|
||||
const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) })
|
||||
expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(unset).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'unset', path: ['providers', 'zombie'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not remove provider settings when its managed credential removal is refused', async () => {
|
||||
const { face, controller, mutate } = await mountSection({
|
||||
unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))),
|
||||
})
|
||||
const failure = await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
credentialRef: 'OPENAI_API_KEY',
|
||||
},
|
||||
)
|
||||
expect(failure).toBe('credential is read-only')
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a transport rejection instead of failing the removal silently', async () => {
|
||||
|
||||
@@ -142,7 +142,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
t,
|
||||
}
|
||||
render(<ModelsSection {...injected} />)
|
||||
return scripted
|
||||
return { ...scripted, controller }
|
||||
}
|
||||
|
||||
/** Open the editor of one configured row and expand its customized fold. */
|
||||
@@ -862,4 +862,20 @@ describe('hand-declared providers', () => {
|
||||
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
|
||||
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reloads the section after creating a hand-declared provider', async () => {
|
||||
const { controller, mutate } = await mountSection()
|
||||
const load = vi.spyOn(controller, 'load')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
|
||||
expect(screen.queryByText(en.customTitle)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/code-runtime/README.md
|
||||
README.md: 2d32a05071efdfa05c336211196bb769ed5a5fc7
|
||||
README.zh.md: 62c9a395ac3cf2b5cd55455ab5a273a1e276f6b7
|
||||
README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
|
||||
README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the SDK generated in the loaded runtime's `language`); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和生成的 TypeScript SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
|
||||
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和按所加载运行时 `language` 生成的 SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
@@ -65,20 +65,12 @@ const ELU_POLL_INTERVAL_MS = 25
|
||||
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
|
||||
const RESERVED_WORDS = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
||||
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
|
||||
'private', 'protected', 'public', 'arguments', 'eval',
|
||||
])
|
||||
|
||||
/** Valid async-function parameter name (the binding global becomes one). */
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
|
||||
/** Error properties whose binding-member replacement would destroy the promised Error contract. */
|
||||
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
|
||||
/**
|
||||
* The seam's language-portable identifier subset (see
|
||||
* `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same
|
||||
* namespace list must be usable against every backend regardless of language.
|
||||
*/
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* The shell a program is wrapped in for the type-strip, matching the
|
||||
@@ -328,10 +320,19 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
|
||||
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
if (namespace.global === 'console' || bindings.has(namespace.global)) {
|
||||
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
|
||||
// `console` is THIS backend's log-capture slot; the dunder entries exist
|
||||
// for the Python side — its seeded/wrapped slots plus the `__debug__`
|
||||
// compile-time constant — refused here too so the namespace list stays
|
||||
// portable across backends. The seam declaration is the single home for
|
||||
// why each entry is reserved.
|
||||
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
if (bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace)
|
||||
@@ -341,13 +342,17 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
for (const namespace of request.bindings) {
|
||||
const descriptor = namespace.errorClass
|
||||
if (!descriptor) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
|
||||
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
|
||||
const member = descriptor.memberNameProperty
|
||||
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
|
||||
@@ -787,7 +787,17 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
['console', /duplicate binding global/],
|
||||
// `$tools` is legal JS but outside the seam's language-portable subset:
|
||||
// the same namespace list must work against every backend's language.
|
||||
['$tools', /not a usable identifier/],
|
||||
// `a$b` pins the second character class too: the old identifier regex
|
||||
// `[A-Za-z0-9_$]*` would have accepted a `$` after the first character.
|
||||
['a$b', /not a usable identifier/],
|
||||
// `lambda` is a Python keyword, refused here directly (not just
|
||||
// transitively) so the worker's adoption of PORTABLE_RESERVED_WORDS is
|
||||
// its own regression, symmetric with the `$tools` case.
|
||||
['lambda', /not a usable identifier/],
|
||||
['console', /reserved binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
@@ -814,7 +824,7 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
|
||||
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/reserved binding global/)
|
||||
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([
|
||||
namespace('tools', 'CallError'),
|
||||
@@ -822,6 +832,14 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
|
||||
// The shared exclusion set covers Python's exception-protocol members and
|
||||
// dunders too, so the same errorClass is valid (or not) on every backend.
|
||||
await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/)
|
||||
// The Python backend's owned globals are refused here too (shared
|
||||
// RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable.
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] }))
|
||||
.rejects.toThrow(/reserved binding global/)
|
||||
})
|
||||
|
||||
it('rejects config values that are not positive numbers', async () => {
|
||||
|
||||
@@ -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/code-runtime/code-runtime/README.md
|
||||
README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602
|
||||
README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489
|
||||
README.md: bb1c20d00a260f643f601c42c6e48722437d5aab
|
||||
README.zh.md: 15fbcecf77b2318acf3b09101802cd032ae426d2
|
||||
|
||||
@@ -11,7 +11,7 @@ This package is the interface third of the capability (the bash trio is the temp
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. |
|
||||
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
|
||||
| `language` | Readonly descriptor: the source language `run` expects. `'typescript'` and `'python'` are the well-known values — those `dsh-tools` presents; only `'typescript'` has a published backend. Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
|
||||
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
|
||||
|
||||
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
|
||||
@@ -20,6 +20,8 @@ Semantics every implementation must honor (contract details in the class JSDoc):
|
||||
|
||||
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets and outer-output cap) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions` + optional `errorClass`), each exposed to the program as one global object of async callables returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
|
||||
|
||||
Binding-global and error-class names are **language-portable**: they must match the identifier subset `[A-Za-z_][A-Za-z0-9_]*` (no JS-only `$`) and clear the seam-exported exclusion sets, so one `bindings` list is valid against every backend regardless of its `language`. The package exports the contract every backend enforces — `PORTABLE_RESERVED_WORDS` (ECMAScript ∪ Python reserved words), `RESERVED_BINDING_GLOBALS` (backend-owned globals such as `console`), `RESERVED_ERROR_MEMBERS` and `DUNDER_MEMBER` (error-member exclusions) — so a name like `$tools`, `lambda`, or `__dsh_main__` makes `run()` reject as seam misuse on any backend, not just some. See `src/index.ts` for the exact sets and rationale.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如 dispose(资源释放)后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 |
|
||||
| `language` | 只读描述符:`run` 期望的源语言(已知值为 `'typescript'`)。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
|
||||
| `language` | 只读描述符:`run` 期望的源语言。已知值为 `'typescript'` 与 `'python'`——`dsh-tools` 能呈现的那些;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
|
||||
| `isolation` | 只读描述符:执行基底(`'worker-thread'`、`'process'`、`'container'`)。供部署与诊断使用,**不构成安全声明**。 |
|
||||
|
||||
每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为敌对对等方(任意绑定名称都会成为自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;dispose 会终止进行中的运行,并且在完成前等待其退出。
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
`CodeRunRequest`(`program`、`bindings`、`signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings` 是 `CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收被拒绝成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]` 和 `error?`(`CodeRunFailure`:`kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`。
|
||||
|
||||
binding-global 与 error-class 名称是**语言可移植**的:必须匹配标识符子集 `[A-Za-z_][A-Za-z0-9_]*`(不含 JS 专有的 `$`)并通过 seam 导出的排除集,因此同一份 `bindings` 列表对每个后端都有效,无论其 `language` 为何。本包导出每个后端都执行的契约——`PORTABLE_RESERVED_WORDS`(ECMAScript ∪ Python 保留字)、`RESERVED_BINDING_GLOBALS`(如 `console` 等后端拥有的 global)、`RESERVED_ERROR_MEMBERS` 与 `DUNDER_MEMBER`(error-member 排除)——因此 `$tools`、`lambda`、`__dsh_main__` 之类的名称会让 `run()` 在任何后端上作为 seam 误用而 reject,而非只在某些后端。确切集合与理由见 `src/index.ts`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tools` 中的 Code Mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。
|
||||
|
||||
@@ -17,6 +17,77 @@ export type {
|
||||
CodeRunResult,
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
* Binding globals EVERY backend refuses because SOME backend owns the slot in
|
||||
* the program's namespace: `console` (the worker's log capture), and
|
||||
* `__dsh_main__`/`__builtins__`/`__name__` (the Python backend's bootstrap
|
||||
* wrapper and seeded module globals — that backend is a later PR in this
|
||||
* stack, see the [portable-identifier Agent
|
||||
* Note](../../../../.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md)),
|
||||
* and `__debug__`. One shared set — rather than each backend refusing only its
|
||||
* own slots — keeps the portability promise real: a namespace list valid on
|
||||
* one backend is valid on all, so a caller cannot pick a name that works on
|
||||
* the worker and collides on Python (or vice versa). `__name__` et al. ARE
|
||||
* valid portable identifiers, so the identifier rule on
|
||||
* `CodeBindingNamespace.global` never rejects them — hence this explicit set.
|
||||
* (Error members differ: {@link DUNDER_MEMBER} refuses every dunder form
|
||||
* wholesale; binding globals refuse only the names listed here.) `__debug__`
|
||||
* is listed for a different reason than a collision: CPython compiles a bare
|
||||
* `__debug__` reference to the constant `True` and rejects any assignment to
|
||||
* the name at COMPILE time, so an injected global under that name is
|
||||
* unreachable from the program — accepted by validation, unusable on the
|
||||
* Python backend, which is exactly the split the shared set exists to prevent.
|
||||
*/
|
||||
export const RESERVED_BINDING_GLOBALS: ReadonlySet<string> = new Set([
|
||||
'console',
|
||||
'__dsh_main__', '__builtins__', '__name__', '__debug__',
|
||||
])
|
||||
|
||||
/**
|
||||
* `CodeBindingErrorClass.memberNameProperty` names EVERY backend refuses, as
|
||||
* one shared contract so a request valid on one backend is valid on all. The
|
||||
* JS `Error` exclusions (`name`, `message`, `stack`) and Python's
|
||||
* exception-protocol members (`args`, `with_traceback`, `add_note`) are
|
||||
* listed by name; dunder-form names (`__x__`, non-empty middle) are refused
|
||||
* wholesale — several are constrained CPython descriptors whose `setattr`
|
||||
* raises while constructing the rejection, and the exact set is an interpreter
|
||||
* version detail. Any other non-empty own property name is accepted everywhere.
|
||||
*/
|
||||
export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([
|
||||
'name', 'message', 'stack',
|
||||
'args', 'with_traceback', 'add_note',
|
||||
])
|
||||
|
||||
/**
|
||||
* Dunder form (`__x__`, non-empty middle): object-protocol slots in Python,
|
||||
* refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend.
|
||||
*/
|
||||
export const DUNDER_MEMBER = /^__.+__$/
|
||||
|
||||
/**
|
||||
* Reserved words of every portable target language (ECMAScript ∪ Python),
|
||||
* refused as {@link CodeBindingNamespace.global} / error-class names by all
|
||||
* backends. Python is a portability target here even though only the
|
||||
* TypeScript worker ships in this PR (the CPython backend is a later PR in the
|
||||
* stack). The portable-identifier contract promises a namespace list valid
|
||||
* on one backend is valid on every backend; a per-language check would let
|
||||
* `lambda` pass the TypeScript backend and fail the Python one. Extending the
|
||||
* seam with a new language means widening this union (a breaking review of
|
||||
* existing binding names, by design).
|
||||
*/
|
||||
export const PORTABLE_RESERVED_WORDS: ReadonlySet<string> = new Set([
|
||||
// ECMAScript reserved words and reserved-in-strict-mode names.
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
||||
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
|
||||
'private', 'protected', 'public', 'arguments', 'eval',
|
||||
// Python 3.x keywords and soft keywords not already above ('type' and '_'
|
||||
// are soft keywords: legal names in practice, reserved here for safety).
|
||||
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'def', 'del', 'elif', 'except', 'from',
|
||||
'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_',
|
||||
])
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
codeRuntime: CodeRuntime
|
||||
@@ -36,7 +107,8 @@ export abstract class CodeRuntime extends Service {
|
||||
* lowercase identifier. Informational, not gating — a consumer that
|
||||
* generates language-specific presentation (typed SDK stubs, usage
|
||||
* instructions) switches on it and fails loud on a language it cannot
|
||||
* present. Well-known value: `'typescript'`.
|
||||
* present. Well-known values: `'typescript'` and `'python'`, those
|
||||
* `dsh-tools` presents; only `'typescript'` has a published backend.
|
||||
*/
|
||||
abstract readonly language: string
|
||||
|
||||
|
||||
@@ -28,9 +28,14 @@ export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] |
|
||||
* of a particular consumer such as Code Mode.
|
||||
*/
|
||||
export interface CodeBindingErrorClass {
|
||||
/** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
|
||||
/** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */
|
||||
name: string
|
||||
/** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
|
||||
/**
|
||||
* Non-empty own property for the member name. The portable exclusion set is
|
||||
* `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty
|
||||
* middle), enforced identically by every backend; any other name —
|
||||
* identifiers or not — is accepted everywhere.
|
||||
*/
|
||||
memberNameProperty: string
|
||||
}
|
||||
|
||||
@@ -42,7 +47,16 @@ export interface CodeBindingErrorClass {
|
||||
* collisions.
|
||||
*/
|
||||
export interface CodeBindingNamespace {
|
||||
/** The global identifier the program sees (must be a valid JS identifier). */
|
||||
/**
|
||||
* The global identifier the program sees. Must match the LANGUAGE-PORTABLE
|
||||
* identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved
|
||||
* words, so the same namespace list works against every backend regardless
|
||||
* of `language` — a JS-only spelling like `$tools` is rejected by design,
|
||||
* not just by the Python backend. Names that satisfy the identifier rule but
|
||||
* name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`,
|
||||
* `__dsh_main__`) are also refused everywhere; see its declaration for the
|
||||
* exact set and why each entry is reserved.
|
||||
*/
|
||||
global: string
|
||||
/** The callable members, keyed by the exact name the program calls. */
|
||||
functions: Record<string, CodeBindingFunction>
|
||||
|
||||
57
packages/code-runtime/code-runtime/tests/reserved.spec.ts
Normal file
57
packages/code-runtime/code-runtime/tests/reserved.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DUNDER_MEMBER,
|
||||
PORTABLE_RESERVED_WORDS,
|
||||
RESERVED_BINDING_GLOBALS,
|
||||
RESERVED_ERROR_MEMBERS,
|
||||
} from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/**
|
||||
* The seam owns the portable-identifier exclusion sets so every backend
|
||||
* enforces one contract: a namespace list valid on one backend is valid on
|
||||
* all. These assertions pin the shared membership backends import rather than
|
||||
* re-declare.
|
||||
*/
|
||||
describe('seam-owned portable identifier exclusions', () => {
|
||||
it('RESERVED_BINDING_GLOBALS covers each backend-owned slot', () => {
|
||||
expect(RESERVED_BINDING_GLOBALS.has('console')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__dsh_main__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__builtins__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__name__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('__debug__')).toBe(true)
|
||||
expect(RESERVED_BINDING_GLOBALS.has('tools')).toBe(false)
|
||||
})
|
||||
|
||||
it('RESERVED_ERROR_MEMBERS covers the JS Error and Python exception-protocol members', () => {
|
||||
for (const name of ['name', 'message', 'stack', 'args', 'with_traceback', 'add_note']) {
|
||||
expect(RESERVED_ERROR_MEMBERS.has(name)).toBe(true)
|
||||
}
|
||||
expect(RESERVED_ERROR_MEMBERS.has('code')).toBe(false)
|
||||
})
|
||||
|
||||
it('DUNDER_MEMBER matches dunder-form names only', () => {
|
||||
expect(DUNDER_MEMBER.test('__dict__')).toBe(true)
|
||||
expect(DUNDER_MEMBER.test('__init__')).toBe(true)
|
||||
expect(DUNDER_MEMBER.test('_private')).toBe(false)
|
||||
expect(DUNDER_MEMBER.test('name')).toBe(false)
|
||||
expect(DUNDER_MEMBER.test('__mid')).toBe(false)
|
||||
// `__` has an empty middle — not a real CPython dunder, so not matched.
|
||||
expect(DUNDER_MEMBER.test('__')).toBe(false)
|
||||
// `____` also has an empty middle between the two `__` pairs — not matched.
|
||||
expect(DUNDER_MEMBER.test('____')).toBe(false)
|
||||
// A single character between the pairs is the shortest real dunder form.
|
||||
expect(DUNDER_MEMBER.test('__x__')).toBe(true)
|
||||
})
|
||||
|
||||
it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => {
|
||||
// ECMAScript-only keyword.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('function')).toBe(true)
|
||||
// Python-only keyword — refused here so the list stays portable.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('lambda')).toBe(true)
|
||||
expect(PORTABLE_RESERVED_WORDS.has('nonlocal')).toBe(true)
|
||||
// Shared keyword.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('class')).toBe(true)
|
||||
// Ordinary identifier is not reserved.
|
||||
expect(PORTABLE_RESERVED_WORDS.has('tools')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -956,7 +956,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
|
||||
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents from the\n * live-preferred session corpus without loading or resuming an Agent. Session\n * query supplies lineage, candidate order, event reads, and live state; this\n * service interprets descriptor mode, activity, and per-child diagnostics\n * without consulting Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded where supported and\n * observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\n */',
|
||||
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'registerProvider(provider: SubagentProvider): () => void',
|
||||
|
||||
@@ -161,7 +161,7 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
export interface TodoItem {
|
||||
/** What this task is — a short imperative line shown in the UI. */
|
||||
content: string
|
||||
/** Lifecycle state. `in_progress` marks the single task being worked now. */
|
||||
/** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f
|
||||
README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad
|
||||
README.md: 81cc57983d83fd19468017b217d4db9978f4e228
|
||||
README.zh.md: 9f875bd80a03d1d0f78625ee98eeaad9d118f871
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
|
||||
### Public API
|
||||
|
||||
@@ -114,9 +114,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs).
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
|
||||
@@ -145,7 +145,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
|
||||
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
|
||||
|
||||
##### Code Mode SDK instructions
|
||||
|
||||
@@ -190,6 +190,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
|
||||
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
|
||||
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
|
||||
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
|
||||
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own).
|
||||
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
|
||||
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
|
||||
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
|
||||
|
||||
### 公开 API
|
||||
|
||||
@@ -114,9 +114,9 @@ ctx.tools.register(defineTool({
|
||||
|
||||
### Code Mode
|
||||
|
||||
在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK;只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap` 和 `ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
|
||||
在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
|
||||
|
||||
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。
|
||||
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。
|
||||
- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发契约的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
|
||||
- **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。
|
||||
- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。
|
||||
@@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及生成的精确 `declare const tools` 块。`both` 会同时公开普通 schema 与此 Code Mode 接口。
|
||||
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。
|
||||
|
||||
##### Code Mode SDK 说明
|
||||
|
||||
@@ -190,6 +190,6 @@ The available tools:
|
||||
- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。
|
||||
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
|
||||
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
|
||||
- **Code Mode 只支持 TypeScript,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。
|
||||
- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)。
|
||||
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
|
||||
- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from './schema.ts'
|
||||
import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts'
|
||||
import { TOOL_REGISTRY_SCHEDULER } from './index.ts'
|
||||
import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts'
|
||||
|
||||
@@ -56,6 +56,111 @@ export const RUN_CODE_NAME = 'run_code'
|
||||
/** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
|
||||
export const SDK_SECTION_ORDER = 150
|
||||
|
||||
/**
|
||||
* The language-specific `run_code` schema text: the tool `description` and its
|
||||
* `code` parameter description, kept together so a language's two model-facing
|
||||
* strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring
|
||||
* `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the
|
||||
* semantics the same language's SDK instructions promise, so the model never
|
||||
* receives a TypeScript-shaped schema beside a Python SDK (or vice versa).
|
||||
*/
|
||||
interface RunCodeFlavor {
|
||||
/** The tool `description` the model sees for this language. */
|
||||
readonly description: string
|
||||
/** The `code` parameter's description for this language. */
|
||||
readonly codeDescription: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TypeScript flavor: the historical default, and the fallback for a schema
|
||||
* read with no runtime mounted ({@link resolveFlavor} owns which readers reach
|
||||
* that). A real assembly always resolves a runtime first, so the model never
|
||||
* sees this fallback outside its own language.
|
||||
*/
|
||||
const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
|
||||
description:
|
||||
'Execute a TypeScript program against the available tools. Write the BODY of an '
|
||||
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
|
||||
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
|
||||
+ 'Only what you print or return comes back — curate it.',
|
||||
codeDescription: 'The program: the body of an async TypeScript function.',
|
||||
}
|
||||
|
||||
/**
|
||||
* The Python flavor: the body of an async function, top-level `await` and
|
||||
* `return`, answer via `print` and/or the returned value, matching
|
||||
* {@link ./py-types.ts}'s SDK instructions.
|
||||
*/
|
||||
const PYTHON_FLAVOR: RunCodeFlavor = {
|
||||
description:
|
||||
'Execute a Python program against the available tools. Write the BODY of an '
|
||||
+ 'async function (top-level `await` and `return` work) and call tools as '
|
||||
+ '`await tools.name(args)` per the declarations in the system prompt. Answer '
|
||||
+ 'with `print(...)` and/or `return <value>` — only that comes back, so curate it.',
|
||||
codeDescription: 'The program: the body of an async Python function.',
|
||||
}
|
||||
|
||||
/**
|
||||
* The languages Code Mode ships a presentation for. Both per-language tables —
|
||||
* {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are
|
||||
* checked against this union with `satisfies`, so a language added to one and
|
||||
* not the other fails `typecheck` instead of waiting for a runtime that reports
|
||||
* it. The tables stay declared `Record<string, …>` because `CodeRuntime.language`
|
||||
* is an unconstrained `string`: this union pins what the harness ships, while the
|
||||
* `Object.hasOwn` guards reject what a mounted runtime may report.
|
||||
*/
|
||||
export type CodeSdkLanguage = 'typescript' | 'python'
|
||||
|
||||
/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link CodeSdkLanguage}. */
|
||||
const RUN_CODE_FLAVORS: Record<string, RunCodeFlavor> = {
|
||||
typescript: TYPESCRIPT_FLAVOR,
|
||||
python: PYTHON_FLAVOR,
|
||||
} satisfies Record<CodeSdkLanguage, RunCodeFlavor>
|
||||
|
||||
/**
|
||||
* The `description` parameter's model-facing description: language-independent
|
||||
* (the UI label contract is the same for every runtime), shared between the
|
||||
* static spec and the language-aware `parameters` getter so the two emissions
|
||||
* can never drift.
|
||||
*/
|
||||
const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
|
||||
= 'Clear, concise description of what this program does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
|
||||
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".'
|
||||
|
||||
/**
|
||||
* Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at
|
||||
* schema-emission time so the model-visible `run_code` schema always matches
|
||||
* the SDK section's language. `peekRuntime` returns `undefined` only when no
|
||||
* runtime is mounted, which reaches this function through definition readers
|
||||
* and `schemas()` — the doc-catalog harvest is the only shipped one, and none
|
||||
* of them feeds a model, because `wireSchemas` calls `requireCodeRuntime`
|
||||
* before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A
|
||||
* mounted runtime whose language has no flavor entry fails loud, exactly as
|
||||
* `requireCodeRuntime` rejects it at assembly. Keeping this table in step with
|
||||
* `SDK_RENDERERS` is the compiler's job ({@link CodeSdkLanguage}); what this
|
||||
* guard owns is the runtime-supplied language neither table knows, which never
|
||||
* yields a wrong-language schema for a real runtime.
|
||||
*/
|
||||
function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor {
|
||||
const runtime = peekRuntime()
|
||||
if (runtime === undefined) {
|
||||
// No runtime mounted: reached by definition readers and `schemas()`, of
|
||||
// which the doc-catalog harvest is the only shipped one. None feeds a
|
||||
// model — `wireSchemas` calls `requireCodeRuntime` before projecting, so
|
||||
// the assembly path never arrives here. Degrade to the TS default.
|
||||
return TYPESCRIPT_FLAVOR
|
||||
}
|
||||
// Own-property read: a language like `toString`/`constructor` would otherwise
|
||||
// resolve an inherited Object.prototype member as a flavor.
|
||||
const flavor = RUN_CODE_FLAVORS[runtime.language]
|
||||
if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) {
|
||||
const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ')
|
||||
throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
|
||||
}
|
||||
return flavor
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `run_code` when the program run itself failed — a program
|
||||
* exception, a budget expiry, an abort, or substrate death. Extends
|
||||
@@ -194,6 +299,13 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
export interface RunCodeBridgeOptions {
|
||||
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
|
||||
requireRuntime: () => CodeRuntime
|
||||
/**
|
||||
* Reads `ctx.codeRuntime` without throwing: `undefined` when none is mounted.
|
||||
* Lets schema emission tell "no runtime" (degrade to TS; the readers that
|
||||
* reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail
|
||||
* loud).
|
||||
*/
|
||||
peekRuntime: () => CodeRuntime | undefined
|
||||
/** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */
|
||||
maxParallel: number
|
||||
/** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */
|
||||
@@ -212,22 +324,22 @@ export interface RunCodeBridgeOptions {
|
||||
* @returns the registry-ready definition.
|
||||
*/
|
||||
export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
|
||||
const { requireRuntime, maxParallel, shapeDispatchLog } = options
|
||||
return defineTool({
|
||||
const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options
|
||||
const definition = defineTool({
|
||||
name: RUN_CODE_NAME,
|
||||
description:
|
||||
'Execute a TypeScript program against the available tools. Write the BODY of an '
|
||||
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
|
||||
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
|
||||
+ 'Only what you print or return comes back — curate it.',
|
||||
// The description and `code` parameter description are placeholders here:
|
||||
// the language-aware getters installed below replace both, resolving the
|
||||
// loaded runtime's flavor at schema-emission time so the schema the MODEL
|
||||
// sees matches the SDK section's language. Argument VALIDATION still keys
|
||||
// off this static spec (defineTool closes over it), which is language-
|
||||
// independent (one required string `code`).
|
||||
description: TYPESCRIPT_FLAVOR.description,
|
||||
parameters: {
|
||||
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
|
||||
code: { type: 'string', required: true, description: TYPESCRIPT_FLAVOR.codeDescription },
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Clear, concise description of what this program does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
|
||||
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".',
|
||||
description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -569,4 +681,22 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
|
||||
// title and reads durable result content without duplicating a large raw
|
||||
// result into the host view payload.
|
||||
})
|
||||
// Resolve the language flavor lazily, at the moment the registry projects the
|
||||
// schema (`schemaOf` destructures `description`/`parameters`). The definition
|
||||
// is minted once at registration, before a runtime is known; deferring here
|
||||
// is the least invasive point that still emits the loaded runtime's language.
|
||||
Object.defineProperty(definition, 'description', {
|
||||
enumerable: true,
|
||||
get: () => resolveFlavor(peekRuntime).description,
|
||||
})
|
||||
Object.defineProperty(definition, 'parameters', {
|
||||
enumerable: true,
|
||||
// Recompile through the same spec→schema projection defineTool used, so
|
||||
// the emitted shape can never drift from the validated one.
|
||||
get: () => parameterSchemaSpecToJsonSchema({
|
||||
code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
|
||||
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
|
||||
}) as unknown as Record<string, unknown>,
|
||||
})
|
||||
return definition
|
||||
}
|
||||
|
||||
@@ -22,8 +22,31 @@ import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
|
||||
import type { JsonSchemaNode } from './json-schema.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import type { CodeSdkLanguage } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
import type { ToolSdkSchema } from './ts-types.ts'
|
||||
import { renderToolsSdkPy } from './py-types.ts'
|
||||
|
||||
/**
|
||||
* Language → SDK-section renderer. The registry looks up the loaded
|
||||
* `ctx.codeRuntime.language` in this table when assembling the `tools:sdk`
|
||||
* section under a non-native mode; a runtime whose language is not a key
|
||||
* fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
|
||||
* new backend language is three parallel edits — a {@link CodeSdkLanguage}
|
||||
* member, an entry here, and a `RUN_CODE_FLAVORS` entry in `code-mode.ts` for
|
||||
* its `run_code` schema strings — plus the renderer function this table points
|
||||
* at. The `satisfies` clause pins this table's key set to that union, which
|
||||
* the flavor table is checked against too, so any of the three left out is a
|
||||
* typecheck failure. What no check reaches is the prose that names the values
|
||||
* instead of deriving them: the seam's `dsh-code-runtime` README pair, its
|
||||
* `CodeRuntime.language` JSDoc, and `docs/core-data-structures/code-runtime.md`
|
||||
* with its zh pair, plus this package's own README pair and the
|
||||
* {@link Config.mode} JSDoc.
|
||||
*/
|
||||
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
|
||||
typescript: renderToolsSdk,
|
||||
python: renderToolsSdkPy,
|
||||
} satisfies Record<CodeSdkLanguage, (schemas: ToolSdkSchema[]) => string>
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
@@ -65,6 +88,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
|
||||
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
|
||||
export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'
|
||||
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
|
||||
|
||||
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
|
||||
@@ -593,8 +617,9 @@ export interface Config {
|
||||
/**
|
||||
* Model presentation. `native` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
|
||||
* Code modes require a TypeScript runtime and fail prompt assembly when it is
|
||||
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
|
||||
* Code modes require a `ctx.codeRuntime` whose `language` has a registered
|
||||
* SDK renderer (TypeScript or Python) and fail prompt assembly when it is
|
||||
* absent or has no renderer. Under `code`, native names in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
@@ -757,6 +782,7 @@ export class ToolRegistry extends Service {
|
||||
? undefined
|
||||
: createRunCodeTool(this, {
|
||||
requireRuntime: () => this.requireCodeRuntime(),
|
||||
peekRuntime: () => this.ctx.get('codeRuntime'),
|
||||
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
|
||||
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
|
||||
})
|
||||
@@ -765,10 +791,21 @@ export class ToolRegistry extends Service {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tools:sdk',
|
||||
order: SDK_SECTION_ORDER,
|
||||
// Regenerate from the calling scope's visible tools in stable order.
|
||||
// Regenerate from the calling scope's visible tools in stable order,
|
||||
// picking the renderer that matches the loaded runtime's language.
|
||||
// `requireCodeRuntime` already validated the language is in the table,
|
||||
// so the guard below is defense-in-depth against a caller that bypassed
|
||||
// it (impossible under normal composition).
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.sdkSchemas(context.scope))
|
||||
const runtime = this.requireCodeRuntime()
|
||||
// Own-property read: a language like `toString`/`constructor` would
|
||||
// otherwise resolve an inherited Object.prototype member as a renderer.
|
||||
const render = SDK_RENDERERS[runtime.language]
|
||||
/* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */
|
||||
if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) {
|
||||
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`)
|
||||
}
|
||||
return render(this.sdkSchemas(context.scope))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -780,11 +817,17 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
||||
const view = this.view(scope)
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
if (this.mode === 'native') {
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
return { schemas, knownNames: [...view.knownNames] }
|
||||
}
|
||||
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
|
||||
// run_code's language-aware description/parameters getters, whose own
|
||||
// flavor-table guard would otherwise surface first. This keeps the
|
||||
// renderer-table rejection the canonical assembly-time error for a
|
||||
// language with no SDK renderer.
|
||||
this.requireCodeRuntime()
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
if (this.mode === 'code') {
|
||||
return {
|
||||
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
|
||||
@@ -801,14 +844,23 @@ export class ToolRegistry extends Service {
|
||||
* behind it — hostage to a code runtime existing even under `mode:
|
||||
* 'native'` (the loop's optional-backend idiom, same as
|
||||
* `sessionPersistence`).
|
||||
*
|
||||
* Assembly and `run_code` execution read separately, so the language is not
|
||||
* bound to a request. Harmless while one published backend exists — both
|
||||
* reads return the same flavor — but a reload that swapped in a second
|
||||
* language between them would hand a program written against one SDK to the
|
||||
* other. Binding it belongs to the PR that publishes that backend, which is
|
||||
* also the first point it can be tested; recorded in the
|
||||
* [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md).
|
||||
*/
|
||||
private requireCodeRuntime(): CodeRuntime {
|
||||
const runtime = this.ctx.get('codeRuntime')
|
||||
if (!runtime) {
|
||||
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
|
||||
}
|
||||
if (runtime.language !== 'typescript') {
|
||||
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
|
||||
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
|
||||
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
|
||||
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
818
packages/core/tools/src/py-types.ts
Normal file
818
packages/core/tools/src/py-types.ts
Normal file
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Code Mode codegen — Python flavor. The pure projection from registered tool schemas to the
|
||||
* Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of
|
||||
* {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry
|
||||
* store, keyed by the loaded {@link @deepseek-ai/dsh-code-runtime#CodeRuntime.language | code
|
||||
* runtime's language}.
|
||||
*
|
||||
* Under `mode: 'code'` the native tool schemas are omitted from the request, so this generated
|
||||
* SDK is the model's ONLY source for each tool's argument names, required fields, types,
|
||||
* descriptions, and canonical output shapes; under `mode: 'both'` the native schemas ship
|
||||
* alongside it and it is one of two. Object-shaped arguments and outputs therefore render as one
|
||||
* named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the
|
||||
* shape survives into the program under the mode that has nothing else to carry it.
|
||||
* @module @deepseek-ai/dsh-tools/src/py-types
|
||||
*/
|
||||
|
||||
import { assertSupportedJsonSchema } from './json-schema.ts'
|
||||
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
|
||||
import type { ToolSdkSchema } from './ts-types.ts'
|
||||
|
||||
/**
|
||||
* The reference grammar's `xid_start xid_continue*` — the set
|
||||
* `str.isidentifier()` accepts on a CPython whose Unicode tables match the
|
||||
* engine's. See {@link isBareIdentifier} for what a version skew does.
|
||||
*/
|
||||
const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u
|
||||
|
||||
/**
|
||||
* Whether a name can be emitted as a bare Python identifier rather than
|
||||
* routed to the subscript/`dict[str, Any]` path.
|
||||
*
|
||||
* Python identifiers are not ASCII: `路径` is as legal a field name as `path`,
|
||||
* and rejecting it would degrade the whole enclosing object, dropping every
|
||||
* field's name, requiredness, and type — information whose only source under
|
||||
* `mode: 'code'` is this generated text.
|
||||
*
|
||||
* NFKC stability is a second and separate condition, because CPython
|
||||
* normalizes identifiers at compile time while JSON keys are compared as
|
||||
* written: `field` would be declared and reachable as `field`, so the SDK would
|
||||
* advertise a key under a spelling the harness never accepts, and two keys
|
||||
* that normalize together would collapse into one declaration. Those names
|
||||
* take the subscript path, which carries their exact bytes.
|
||||
*
|
||||
* `IDENTIFIER`'s equivalence to `str.isidentifier()` was measured across 21
|
||||
* samples with zero divergence, on Node 22.23.1 against CPython 3.9.6 — every
|
||||
* sample sits inside the two versions' shared tables, and the skew characters
|
||||
* below are exactly where that pair diverges. The predicate as a whole is
|
||||
* deliberately stricter than `isidentifier()`, which does not test NFKC
|
||||
* stability: `'field'.isidentifier()` is True and this returns false.
|
||||
*
|
||||
* Both conditions are evaluated against the ENGINE's Unicode tables, and the
|
||||
* two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}`
|
||||
* follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython
|
||||
* follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython
|
||||
* older than the engine is the dangerous direction: a character added to either
|
||||
* property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; U+1E4D0
|
||||
* Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per `DerivedAge.txt`; all
|
||||
* four are NFKC-stable and accepted here, and all four are `Cn` on that 3.9.6,
|
||||
* which rejects them) is emitted bare and its tokenizer refuses the character,
|
||||
* taking the whole SDK block down — the same parseability invariant
|
||||
* {@link UNPRINTABLE}, {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING}
|
||||
* exist for. Both properties carry it: a character added only to `XID_Continue`
|
||||
* passes the trailing `\p{XID_Continue}*` in a tail position and fails the same
|
||||
* way — U+200C ZWNJ and U+200D ZWJ are that case, gaining `XID_Continue` in UCD
|
||||
* 15.1 and absent from it in 13.0.0, 14.0.0 and 15.0.0, so `a\u{200C}b` is
|
||||
* emitted bare here while `isidentifier()` is False on 3.9.6 and on 3.12.13
|
||||
* (15.0.0). A CPython newer than the engine only routes a legal name to the
|
||||
* subscript/`dict[str, Any]` path: less readable, still correct. The NFKC
|
||||
* condition reduces to the same skew, since normalization stability guarantees
|
||||
* an assigned character's normalization never changes afterwards.
|
||||
*
|
||||
* This predicate is not the only reader of engine tables. {@link camelCase}
|
||||
* reads them at three further points — its split set, its head test, and its
|
||||
* `toUpperCase()` case mapping — and this predicate's verdict gates none of
|
||||
* them: a class name derived there reaches emitted text whenever any object
|
||||
* shape in the tool's schema declares a `TypedDict`, including for a tool this
|
||||
* predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never
|
||||
* reaches the skew here (the `-` rejects it outright) yet emits `class
|
||||
* Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari arrived two
|
||||
* releases after its tables. The case mapping is a separate table rather than
|
||||
* an XID membership test, and it fails on names both conditions above accept:
|
||||
* `\u{019B}` is XID_Start and NFKC-stable, so this predicate accepts it and
|
||||
* `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to
|
||||
* `\u{A7DC}` — unassigned in that CPython, whose own `.upper()` is the identity
|
||||
* here — and the declared `class \u{A7DC}Args` fails with `invalid
|
||||
* non-printable character U+A7DC`. Closing the exposure therefore covers all
|
||||
* four read points, not this predicate alone; it needs the target interpreter's
|
||||
* version, which the backend reporting `language: 'python'` owns and which is
|
||||
* unpublished on this base, so the note records it as that PR's decision.
|
||||
*
|
||||
* The `ts-types` sibling keeps its own ASCII rule rather than sharing this
|
||||
* one: ECMAScript identifiers are a different set (`$`) and are never
|
||||
* normalized, so one predicate cannot be correct for both. ZWJ/ZWNJ are not
|
||||
* part of that difference — both sets carry them on the engine's tables; what
|
||||
* separates the two there is the CPython table version above.
|
||||
* @param name - the raw schema field or tool name.
|
||||
* @returns whether the name can be emitted bare.
|
||||
*/
|
||||
function isBareIdentifier(name: string): boolean {
|
||||
return IDENTIFIER.test(name) && name.normalize('NFKC') === name
|
||||
}
|
||||
|
||||
/**
|
||||
* Python hard keywords: reserved everywhere, so a tool or field named
|
||||
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
|
||||
* (``tools.class`` would be a SyntaxError in the model program) and not as a
|
||||
* class-syntax `TypedDict` field. Such a tool renders under subscript access
|
||||
* and such an object degrades to ``dict[str, Any]`` — the model still reaches
|
||||
* every tool and field without collisions.
|
||||
* Soft keywords (``match``, ``case``, ``type``, ``_`` — the language
|
||||
* reference's whole set) are deliberately ABSENT: each is special in exactly
|
||||
* one syntactic position — a statement head (``match``, ``type``), a ``match``
|
||||
* statement's clause head (``case``), or a pattern (``_``) — so ``match: str``
|
||||
* as a field and ``async def match(...)`` as a method are both legal, and
|
||||
* including them would needlessly degrade common search/regex tool fields to
|
||||
* ``dict[str, Any]``. Underscore-leading names are handled separately, not
|
||||
* here: a non-dunder ``__token`` name-mangles, a dunder present on
|
||||
* ``object``/``type`` resolves before the proxy hook, and implicit
|
||||
* special-method lookup bypasses the hook.
|
||||
*/
|
||||
const RESERVED = new Set([
|
||||
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
|
||||
'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
|
||||
'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
|
||||
'return', 'try', 'while', 'with', 'yield',
|
||||
// Not a keyword, but CPython refuses to ASSIGN it at compile time
|
||||
// (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict
|
||||
// field, a parameter name, and a keyword argument all are.
|
||||
'__debug__',
|
||||
])
|
||||
|
||||
/** `typing` symbols this module may emit, in the deterministic import order. */
|
||||
const TYPING_ORDER = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict'] as const
|
||||
|
||||
/** `indent`-deep line prefix (four spaces per level to match PEP 8 output). */
|
||||
function pad(indent: number): string {
|
||||
return ' '.repeat(indent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collector threaded through {@link renderType}: the emitted `TypedDict` class
|
||||
* declarations (nested classes precede the parent that references them), the
|
||||
* class names already taken (for collision suffixing), a per-base collision
|
||||
* counter, and the `typing` symbols the render actually used.
|
||||
*/
|
||||
interface RenderState {
|
||||
readonly classes: string[]
|
||||
readonly usedClassNames: Set<string>
|
||||
/** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */
|
||||
readonly nextClassCounter: Map<string, number>
|
||||
readonly typing: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Cc` code points that survive the whitespace collapse in {@link describe}
|
||||
* and have no printable form: the C0 controls, DEL, and the C1 controls. Only
|
||||
* U+0009 to U+000D are absent, because ECMAScript `\s` already collapsed them —
|
||||
* `\s` is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, so no C1 code point is
|
||||
* in it and the whole U+0080 to U+009F block reaches this rule intact. Those
|
||||
* are not hypothetical input: they are what Windows-1252 bytes 0x80 to 0x9F
|
||||
* (smart quotes, em dash) become when decoded as Latin-1.
|
||||
* CPython rejects source containing a NUL outright
|
||||
* (`SyntaxError: source code string cannot contain null bytes`), whether it
|
||||
* sits in a docstring or in a comment, so one such byte anywhere in a schema
|
||||
* description would make the whole generated SDK unparseable — under
|
||||
* `mode: 'code'`, the model's only declaration of the tools. The rest are
|
||||
* legal but invisible; escaping them with the same rule keeps the emitted text
|
||||
* readable and the treatment uniform.
|
||||
*
|
||||
* The boundary is the category, not per-code-point addressability: `\xNN`
|
||||
* addresses U+0000 to U+00FF, so one escape form covers `Cc` exactly. The
|
||||
* invisible `Cf` formatting characters pass through by design — of them only
|
||||
* U+00AD soft hyphen would fit `\xNN` at all, and escaping that one while
|
||||
* U+200B ZWSP, U+200E/U+200F bidi marks, and U+2060 word joiner passed through
|
||||
* would leave a rule that is neither category- nor addressability-shaped. The
|
||||
* whole family is legal in both consumers, since only LF and CR terminate a
|
||||
* Python string literal or a `#` comment. That set is the tokenizer's, not
|
||||
* `str.splitlines()`': NEL (U+0085), LS (U+2028), and PS (U+2029) split a
|
||||
* string at run time but do not end a physical line in source — measured on
|
||||
* CPython 3.9.6 and 3.12.13, each accepted in both positions with the value
|
||||
* round-tripping — so they are safe raw wherever they reach emitted text
|
||||
* unescaped, which for all three is `JSON.stringify`, at two call sites:
|
||||
* {@link pyScalar}'s literal path, and the subscript tool-name comment's own
|
||||
* call, which a name carrying any of them always reaches, none being
|
||||
* `XID_Continue`. The `description` path escapes NEL under the class above and
|
||||
* folds LS and PS in {@link describe}'s `\s+` collapse, both being `\s`.
|
||||
*/
|
||||
const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g
|
||||
|
||||
/**
|
||||
* Unpaired surrogate code points, escaped by {@link describe} as `\uNNNN` —
|
||||
* its own form, since `\xNN` stops at U+00FF. The `u` flag is what makes this
|
||||
* the LONE ones: in Unicode mode a well-formed pair is a single astral code
|
||||
* point outside D800 to DFFF, so an emoji in a description survives untouched.
|
||||
*
|
||||
* This is the NUL case from {@link UNPRINTABLE}, not the invisible-character
|
||||
* case. Python source must be UTF-8-encodable and a lone surrogate is not, so
|
||||
* `compile()` raises `UnicodeEncodeError: surrogates not allowed` for one
|
||||
* anywhere in the text — measured on 3.9 for a string literal and for a `#`
|
||||
* comment alike. A raw or MCP tool description reaches this: `JSON.parse` on a
|
||||
* wire `"\ud800"` escape yields exactly such a code point.
|
||||
*/
|
||||
const LONE_SURROGATE = /[\ud800-\udfff]/gu
|
||||
|
||||
/**
|
||||
* The collapsed one-line `description` of a schema node (byte-stable across
|
||||
* formatting churn), or `undefined` when the node carries none. Every caller
|
||||
* passes an object — a validated property node, the `ToolSdkSchema` itself, or
|
||||
* the `{ description }` wrapper {@link docLines} synthesizes — so only the
|
||||
* description field needs guarding. A description that collapses
|
||||
* to nothing (empty, or whitespace only) is `undefined` too: it documents the
|
||||
* node no better than an absent one, and emitting it would leave an empty
|
||||
* `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace
|
||||
* folds, so a description of whitespace plus one surviving control character is
|
||||
* NOT absent: it collapses to that character's visible escape.
|
||||
*
|
||||
* Control characters left over after the whitespace collapse are rendered as
|
||||
* their `\xNN` escapes (see {@link UNPRINTABLE}) and unpaired surrogates as
|
||||
* their `\uNNNN` escapes (see {@link LONE_SURROGATE}); the escape's own backslash is
|
||||
* emitted literally by both consumers, since {@link docLines} doubles it into a
|
||||
* Python source escape and a `#` comment carries it verbatim.
|
||||
*/
|
||||
function describe(schema: object): string | undefined {
|
||||
const description = (schema as Record<string, unknown>).description
|
||||
if (typeof description !== 'string') return undefined
|
||||
const collapsed = description
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
.replace(LONE_SURROGATE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
||||
.trim()
|
||||
return collapsed.length === 0 ? undefined : collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line docstring for a tool `description`, or no lines when there is none.
|
||||
* Backslashes are doubled first, every quote is escaped, and a trailing
|
||||
* backslash cannot survive: a description ending in `"` or an odd backslash
|
||||
* would otherwise merge with (or escape) the closing triple quote and make
|
||||
* the generated block — Code Mode's only SDK — syntactically invalid Python.
|
||||
*/
|
||||
function docLines(description: unknown, indent: number): string[] {
|
||||
const collapsed = describe({ description })
|
||||
if (collapsed === undefined) return []
|
||||
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
return [`${pad(indent)}"""${escaped}"""`]
|
||||
}
|
||||
|
||||
/**
|
||||
* CamelCase a name into a Python type identifier: non-identifier characters
|
||||
* split words, `_` splits too (it is `XID_Continue`, so the split set names it
|
||||
* explicitly), and a head that cannot start an identifier takes a `Tool`
|
||||
* prefix. Unicode survives, so a `路径` field yields `路径`-based class names
|
||||
* instead of collapsing to the bare prefix. A character that is not
|
||||
* `XID_Continue` splits even when it is a letter, so a name whose NFKC folding
|
||||
* would leave the identifier set is not carried through — the split set is the
|
||||
* grammar's, not an ASCII approximation of it.
|
||||
*
|
||||
* The result is NFKC-normalized: these names are generated, never matched
|
||||
* against a JSON key, so normalizing is free here and keeps what CPython
|
||||
* compiles identical to what is emitted — unlike {@link isBareIdentifier},
|
||||
* which must reject unstable names outright. Normalizing AFTER the prefix
|
||||
* decision is what makes that hold at the seam the prefix creates: `Tool` +
|
||||
* a combining-mark head composes there (`U+0301` gives `Tooĺ`, U+013A), so
|
||||
* normalizing only the un-prefixed part would emit a name CPython compiles to
|
||||
* a different symbol. The second call is idempotent on the un-prefixed arm.
|
||||
*
|
||||
* The split set, the head test, and `toUpperCase()` all read the engine's
|
||||
* Unicode tables, so this function carries the same version skew
|
||||
* {@link isBareIdentifier} documents, by paths independent of it: a class name
|
||||
* derived here reaches emitted text whenever any object shape in the tool's
|
||||
* schema declares a `TypedDict`, and the predicate's verdict on the tool name
|
||||
* does not gate that. The case mapping is the one that can fail on a name the
|
||||
* predicate accepted; the worked example is there.
|
||||
* @param raw - the schema field or tool name to derive from.
|
||||
* @returns a class-name segment safe to emit.
|
||||
*/
|
||||
function camelCase(raw: string): string {
|
||||
const joined = raw
|
||||
.split(/[^\p{XID_Continue}]+|_+/u)
|
||||
.filter(part => part.length > 0)
|
||||
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join('')
|
||||
.normalize('NFKC')
|
||||
return (/^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}`).normalize('NFKC')
|
||||
}
|
||||
|
||||
/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
|
||||
const MAX_CLASS_NAME_BASE = 120
|
||||
|
||||
/**
|
||||
* Deepest `list[…]` nesting emitted into one annotation before the item type
|
||||
* degrades to `Any`. CPython's tokenizer rejects a logical line holding more
|
||||
* than 200 simultaneously-open brackets (`MAXLEVEL`, `SyntaxError: too many
|
||||
* nested parentheses`), so an array chain deeper than that would render an SDK
|
||||
* block that is not valid Python at all — the same failure the docstring
|
||||
* escaping in {@link docLines} exists to prevent. 180 leaves headroom for the
|
||||
* few brackets an annotation can add around the chain, all of which count
|
||||
* toward the same limit. Per emission site, counting brackets open at the
|
||||
* chain's innermost point:
|
||||
*
|
||||
* - Return annotation, `async def f(self, args: X) -> chain:` — 180 `list[`
|
||||
* plus an innermost `Literal[`. The parameter list's `(` closed at the `)`
|
||||
* before the `->`, so it is NOT open here: 181.
|
||||
* - TypedDict field, `field: NotRequired[chain]` — a class-body line with no
|
||||
* other open bracket, and its children start at `listDepth: 1` to reserve
|
||||
* the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. Required fields
|
||||
* share that start for uniformity, spending one level of representable depth
|
||||
* on a bracket they never emit.
|
||||
* - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS
|
||||
* still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the
|
||||
* worst case. Reachable only through a raw `register()` whose `parameters`
|
||||
* is an array reached from the root through `oneOf` arms alone — the root
|
||||
* array itself, or one nested under any depth of unions, since an arm
|
||||
* inherits the enclosing depth unchanged (`A | B` opens no bracket). An
|
||||
* object ancestor takes it out of this case: its fields restart the chain at
|
||||
* the 181 site. `defineTool` compiles an object root, so the annotation is a
|
||||
* bare TypedDict class name or a one-bracket `dict[str, Any]` when that
|
||||
* object degrades — never a chain.
|
||||
*
|
||||
* A CPython grammar limit, not a deployment choice, so it is fixed rather than
|
||||
* configurable. The sibling `ts-types` renderer needs no counterpart: nothing
|
||||
* in the TypeScript grammar bounds nesting, and its SDK block is never type-
|
||||
* checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B`
|
||||
* chain and nested objects render as separate `class` statements, so neither
|
||||
* accumulates open brackets at any depth. The invariant this cap serves is
|
||||
* grammatical validity; see the `oneOf` arm in {@link renderType} for the one
|
||||
* interpreter limit deliberately left uncapped.
|
||||
*/
|
||||
const MAX_LIST_NESTING = 180
|
||||
|
||||
/**
|
||||
* Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for
|
||||
* why capping keeps the render linear). `slice` counts UTF-16 code units, so
|
||||
* an astral character straddling the boundary would be cut in half and leave a
|
||||
* lone surrogate — not an identifier character, and not even well-formed text;
|
||||
* drop it rather than emit it.
|
||||
*/
|
||||
function capClassNameBase(base: string): string {
|
||||
if (base.length <= MAX_CLASS_NAME_BASE) return base
|
||||
const capped = base.slice(0, MAX_CLASS_NAME_BASE)
|
||||
return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
|
||||
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
|
||||
* derive from their parent's allocated name (`ParentChild`), so an unbounded
|
||||
* schema of single-field objects would otherwise grow each name by one field
|
||||
* per level and the sum of all names to Θ(depth²). Capping the base keeps each
|
||||
* name — and the total emitted text — linear in depth. Collisions resume from
|
||||
* the per-base counter in `state.nextClassCounter` rather than rescanning from
|
||||
* `2`, so a deep chain sharing one capped base stays O(1) per allocation
|
||||
* (amortized) instead of Θ(depth²) in time.
|
||||
*/
|
||||
function allocateClassName(base: string, state: RenderState): string {
|
||||
const capped = capClassNameBase(base)
|
||||
let name = capped
|
||||
if (state.usedClassNames.has(name)) {
|
||||
let n = state.nextClassCounter.get(capped) ?? 2
|
||||
while (state.usedClassNames.has(`${capped}${n}`)) n++
|
||||
name = `${capped}${n}`
|
||||
state.nextClassCounter.set(capped, n + 1)
|
||||
}
|
||||
state.usedClassNames.add(name)
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a child-name segment to a parent class-name base, capping the result
|
||||
* at {@link MAX_CLASS_NAME_BASE}. Capping AT PROPAGATION (not only inside
|
||||
* {@link allocateClassName}) keeps each level O(1): a deep `oneOf`- or
|
||||
* object-chain would otherwise carry an ever-growing ConsString down the tree
|
||||
* and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²).
|
||||
* The bounded base plus the collision counter still yields unique names.
|
||||
*
|
||||
* The join is NFKC-normalized because both sides are separately normalized yet
|
||||
* their concatenation need not be: a base ending in a Hangul L jamo or LV
|
||||
* syllable composes with a following V or T jamo head (`가` + `ᆨ` gives `각`),
|
||||
* so the emitted class name would differ from the symbol CPython compiles, and
|
||||
* two byte-distinct names could fold onto one — `usedClassNames` dedupes by the
|
||||
* raw bytes, so the collision counter would not see it. Normalizing costs
|
||||
* O(cap + segment) per level, the same order as the `slice` it feeds. The other
|
||||
* two join points need no counterpart: `Args`/`Output` start with `A`/`O` and
|
||||
* {@link allocateClassName}'s suffix is digits, none of which compose backwards.
|
||||
*/
|
||||
function childClassName(base: string, segment: string): string {
|
||||
return capClassNameBase(`${base}${segment}`.normalize('NFKC'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one validated scalar as Python literal text (`True`/`False`,
|
||||
* JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
|
||||
* type renders directly as `None`, and the unified validator rejects a null
|
||||
* `const`/`enum` entry on every other scalar type.
|
||||
*
|
||||
* A beyond-safe-range integral number takes `BigInt` digits rather than
|
||||
* `String`: Python integers are arbitrary-precision, so the emitted digits ARE
|
||||
* the value the model programs against, and `String` can give a different
|
||||
* integer than the double holds (`2 ** 60` prints the rounded `...847000`, not
|
||||
* the exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`).
|
||||
* `String`'s rounding is not a bug in it: `Number::toString` emits the shortest
|
||||
* decimal string that re-reads to the same double, then pads to the exponent
|
||||
* with zeros (1 significant digit for `1e20`, 16 for `2 ** 60`) — and when the
|
||||
* shortest string is shorter than the double's exact value, those padded digits
|
||||
* name an integer no double holds. Passing one back would have to cross the
|
||||
* argument boundary as a JSON number — a double again — so the SDK would
|
||||
* document a value no program can pass. `BigInt` needs no case split: where
|
||||
* `String` is already exact (`2 ** 53`, `1e20`) the two agree byte for byte,
|
||||
* and where it is not, `BigInt` is the exact one. The TS flavor needs no
|
||||
* counterpart at all: its literal is re-read by a JS parser back into the same
|
||||
* double.
|
||||
*
|
||||
* `JSON.stringify` is also what keeps this path's output parseable, and it is
|
||||
* the only thing that does. It covers both classes of hazard: the two kinds of
|
||||
* code point CPython refuses anywhere in source — NUL among the C0 controls,
|
||||
* and the whole D800–DFFF unpaired-surrogate block, escaped under ES2019
|
||||
* well-formed stringification, which the engines range guarantees — and the
|
||||
* ones that break this line in particular, a bare `"` closing the literal
|
||||
* early, a trailing odd backslash eating the closing quote, and a bare LF/CR
|
||||
* ending it before its terminator. The `description` path carries
|
||||
* {@link UNPRINTABLE} and {@link LONE_SURROGATE} because nothing quotes it,
|
||||
* and folds newlines in {@link describe}.
|
||||
*
|
||||
* That leans on a coincidence worth naming: every escape `JSON.stringify` can
|
||||
* emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python
|
||||
* escape denoting the same character, so the emitted `Literal[...]` both
|
||||
* parses and decodes back to the value the schema declared. DEL, the C1
|
||||
* controls (NEL among them), and LS/PS (U+2028/U+2029) do reach it raw —
|
||||
* legal but invisible, byte-for-byte as in the TS flavor; escaping them is a
|
||||
* both-flavors change. Those last three are legal here for the reason
|
||||
* {@link UNPRINTABLE} records: they are `str.splitlines()` boundaries, not
|
||||
* tokenizer line terminators. The subscript tool-name comment quotes its name
|
||||
* through its own call to the same `JSON.stringify`, never through this
|
||||
* function, and inherits both halves — escapes and pass-throughs alike.
|
||||
*/
|
||||
function pyScalar(value: JsonSchemaScalar): string {
|
||||
if (value === true) return 'True'
|
||||
if (value === false) return 'False'
|
||||
if (typeof value === 'string') return JSON.stringify(value)
|
||||
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||
return BigInt(value).toString()
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to
|
||||
* the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
|
||||
* parameters to int/bool/str/bytes/enum/None: a non-integral number
|
||||
* `const`/`enum` emits a float literal (`Literal[1.5]`) a strict checker would
|
||||
* reject. An integral one does not deviate — {@link pyScalar} emits int digits,
|
||||
* including for the beyond-safe-range values it widens through `BigInt`, and
|
||||
* PEP 586 admits int parameters. Harmless either way — the stub is advisory
|
||||
* prompt text, only required to parse — and keeping the exact value
|
||||
* communicates the constraint to the model.
|
||||
*/
|
||||
function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string {
|
||||
if (node.const !== undefined) {
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${pyScalar(node.const)}]`
|
||||
}
|
||||
if (node.enum !== undefined) {
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${node.enum.map(pyScalar).join(', ')}]`
|
||||
}
|
||||
return broad
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one JSON-Schema node to a Python type expression, threading `state` to
|
||||
* collect the `TypedDict` declarations and `typing` symbols a full render
|
||||
* needs. `className` is the name to give an object node with properties (and
|
||||
* the prefix for its nested objects). Handles every unified schema construct —
|
||||
* `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`),
|
||||
* `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any`
|
||||
* without throwing, the same trusted-after-validation stance as the sibling
|
||||
* {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the
|
||||
* context-free entry point; this is the collecting core.
|
||||
*/
|
||||
function renderType(schema: unknown, className: string, state: RenderState): string {
|
||||
interface Frame {
|
||||
// A validated JSON-schema node past the root `assertSupportedJsonSchema`
|
||||
// (the root frame's schema is asserted before any frame is built), so the
|
||||
// walk reads its fields without casts — the same typed-frame shape as the
|
||||
// sibling ts-types renderer.
|
||||
schema: JsonSchemaNode
|
||||
className: string
|
||||
phase: 'start' | 'children'
|
||||
kind?: 'oneOf' | 'array' | 'typeddict'
|
||||
node?: JsonSchemaNode
|
||||
/** Open `list[` brackets enclosing this node in the annotation being built ({@link MAX_LIST_NESTING}). */
|
||||
listDepth: number
|
||||
children: { schema: JsonSchemaNode; className: string; listDepth: number }[]
|
||||
childIndex: number
|
||||
childTypes: string[]
|
||||
entries: [string, JsonSchemaNode][]
|
||||
allocated?: string
|
||||
}
|
||||
const newFrame = (schema: JsonSchemaNode, className: string, listDepth: number): Frame =>
|
||||
({ schema, className, phase: 'start', listDepth, children: [], childIndex: 0, childTypes: [], entries: [] })
|
||||
try {
|
||||
// Validate the WHOLE tree once, then trust it — the same contract the
|
||||
// sibling ts-types renderer follows at a typed same-process seam. Every
|
||||
// node past this point is a validated JSON-schema node, so the walk reads
|
||||
// its fields without re-checking. An unsupported or malformed schema throws
|
||||
// here (before anything is emitted) and degrades to `Any`, the Python
|
||||
// counterpart of the TS flavor's `unknown`.
|
||||
assertSupportedJsonSchema(schema)
|
||||
const frames: Frame[] = [newFrame(schema, className, 0)]
|
||||
let result: string | undefined
|
||||
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||
const finish = (type: string): void => {
|
||||
frames.pop()
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) result = type
|
||||
else parent.childTypes.push(type)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
if (frame === undefined) break
|
||||
|
||||
if (frame.phase === 'children') {
|
||||
if (frame.childIndex < frame.children.length) {
|
||||
const child = frame.children[frame.childIndex]
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing python render child')
|
||||
frame.childIndex++
|
||||
frames.push(newFrame(child.schema, child.className, child.listDepth))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
// Concatenate incrementally (template literal, not `Array.join`): V8
|
||||
// builds a lazy ConsString, so a deep oneOf chain materializes once
|
||||
// at the root instead of re-materializing the accumulated string at
|
||||
// every level (which `join` would, making it Θ(depth²)). This matches
|
||||
// the array arm's template-literal laziness and ts-types' composable-
|
||||
// document approach — the whole walk stays linear in schema depth.
|
||||
let union = ''
|
||||
for (const [index, childType] of frame.childTypes.entries()) {
|
||||
union = index === 0 ? childType : `${union} | ${childType}`
|
||||
}
|
||||
finish(union)
|
||||
continue
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
if (frame.kind === 'array') {
|
||||
// `list[A | B]` needs no parentheses in Python. Array frames always
|
||||
// schedule exactly one child, so its type is present.
|
||||
/* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
|
||||
finish(`list[${frame.childTypes[0] ?? 'Any'}]`)
|
||||
continue
|
||||
}
|
||||
// typeddict: assemble AFTER the children so any nested class this one
|
||||
// references is already declared (declaration order = reference order).
|
||||
const node = frame.node
|
||||
const name = frame.allocated
|
||||
/* v8 ignore next -- typeddict frames always set node and allocated at start. */
|
||||
if (node === undefined || name === undefined) throw new Error('missing typeddict frame state')
|
||||
const required = new Set(node.required)
|
||||
const lines = [`class ${name}(TypedDict):`]
|
||||
for (let index = 0; index < frame.entries.length; index++) {
|
||||
const entry = frame.entries[index]
|
||||
const fieldType = frame.childTypes[index]
|
||||
/* v8 ignore next -- entries and childTypes correspond one-to-one. */
|
||||
if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type')
|
||||
const [field, fieldSchema] = entry
|
||||
// The parent node passed assertSupportedJsonSchema, so every property
|
||||
// value is a validated schema node.
|
||||
const description = describe(fieldSchema)
|
||||
if (description !== undefined) lines.push(`${pad(1)}# ${description}`)
|
||||
if (required.has(field)) {
|
||||
lines.push(`${pad(1)}${field}: ${fieldType}`)
|
||||
} else {
|
||||
state.typing.add('NotRequired')
|
||||
lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`)
|
||||
}
|
||||
}
|
||||
// TypedDict syntax cannot express openness, so an open object states it
|
||||
// in-band: the annotation is advisory either way, and `mode: 'code'`
|
||||
// omits the native schemas, making this line the model's only signal
|
||||
// that extra keys are accepted.
|
||||
if (node.additionalProperties !== false) {
|
||||
lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`)
|
||||
}
|
||||
// A closed empty object still needs a class body (`pass`) to be valid
|
||||
// Python; the declared emptiness is the information.
|
||||
if (lines.length === 1) lines.push(`${pad(1)}pass`)
|
||||
state.classes.push(lines.join('\n'))
|
||||
finish(name)
|
||||
continue
|
||||
}
|
||||
|
||||
frame.phase = 'children'
|
||||
const node = frame.schema
|
||||
if (node.oneOf !== undefined) {
|
||||
frame.kind = 'oneOf'
|
||||
// A union renders as `A | B` — no brackets of its own, so the branches
|
||||
// inherit the enclosing depth unchanged.
|
||||
//
|
||||
// Union LENGTH is deliberately uncapped, unlike list nesting. The two
|
||||
// limits are different in kind: >200 open brackets is a SyntaxError
|
||||
// from the tokenizer, so the text is not Python; a long `A | B | …`
|
||||
// chain is grammatically valid at any length and only defeats CPython's
|
||||
// C-recursion when `compile()` walks the left-nested BinOp spine
|
||||
// (measured: 1,000 branches compile, 5,000 raise RecursionError). This
|
||||
// block is prompt text — nothing compiles it — so that limit costs
|
||||
// nothing here, while capping would retire the deep-chain tests that
|
||||
// pin the walk's linear time and the class-name propagation cap. The
|
||||
// standard this renderer holds is grammatical validity, not
|
||||
// compilability under one interpreter's stack.
|
||||
frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth }))
|
||||
continue
|
||||
}
|
||||
if (node.type === undefined) {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
continue
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'string': finish(renderConstrainedScalar(node, 'str', state)); break
|
||||
case 'number': finish(renderConstrainedScalar(node, 'float', state)); break
|
||||
case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break
|
||||
case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break
|
||||
case 'null': finish('None'); break
|
||||
case 'array': {
|
||||
if (node.items === undefined) {
|
||||
state.typing.add('Any')
|
||||
finish('list[Any]')
|
||||
break
|
||||
}
|
||||
// Past MAX_LIST_NESTING another `list[` would push the annotation
|
||||
// beyond CPython's open-bracket limit and make the whole SDK block
|
||||
// unparseable, so the chain degrades here instead — an unusable
|
||||
// annotation either way, and this one is valid Python.
|
||||
if (frame.listDepth >= MAX_LIST_NESTING) {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
break
|
||||
}
|
||||
// An array of objects names its item type after the array field.
|
||||
frame.kind = 'array'
|
||||
frame.children = [{ schema: node.items, className: frame.className, listDepth: frame.listDepth + 1 }]
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
// A missing `properties` is an empty property map, exactly as the
|
||||
// unified validator and the TS renderer read it — NOT an unknown
|
||||
// shape. The openness of the resulting empty object is decided below,
|
||||
// so a closed empty object still declares an empty TypedDict rather
|
||||
// than a permissive `dict[str, Any]`.
|
||||
const entries = Object.entries(node.properties ?? {})
|
||||
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
|
||||
// there is no naming context to declare into, so degrade. This reads
|
||||
// the CALL's className, not `frame.className`: the marker belongs to
|
||||
// the whole walk, and frames propagate a derived name (a `oneOf`
|
||||
// branch of the context-free root gets the index-derived name `1` —
|
||||
// `childClassName` concatenates and caps, it does not go through
|
||||
// `camelCase`), so a per-frame read would declare classes the caller
|
||||
// has no way to receive, under a name that is not even a legal
|
||||
// identifier: `class 1(TypedDict):`. A field
|
||||
// name that is not a legal Python attribute is inexpressible as a
|
||||
// class-syntax `TypedDict` field, so such an object degrades whole.
|
||||
// A leading-double-underscore non-dunder field (`__token`) would be
|
||||
// NAME-MANGLED inside class syntax (`_ClassName__token`), describing a
|
||||
// different JSON key than the registered schema — degrade like any
|
||||
// other inexpressible field name.
|
||||
if (className === '' || !entries.every(([name]) => isBareIdentifier(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) {
|
||||
state.typing.add('Any')
|
||||
finish('dict[str, Any]')
|
||||
break
|
||||
}
|
||||
// An OPEN empty object is any dict; a CLOSED empty object declares an
|
||||
// empty TypedDict so "no keys accepted" survives into the SDK.
|
||||
if (entries.length === 0 && node.additionalProperties !== false) {
|
||||
state.typing.add('Any')
|
||||
finish('dict[str, Any]')
|
||||
break
|
||||
}
|
||||
frame.kind = 'typeddict'
|
||||
frame.node = node
|
||||
frame.allocated = allocateClassName(frame.className, state)
|
||||
state.typing.add('TypedDict')
|
||||
frame.entries = entries
|
||||
// A field annotation is its own logical line, so nesting restarts —
|
||||
// at 1, reserving the bracket an optional field's `NotRequired[…]`
|
||||
// wraps around it. frame.allocated was assigned three statements up;
|
||||
// the ?? arm is for the type system only.
|
||||
/* v8 ignore next -- allocated is always set before children are built. */
|
||||
frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)), listDepth: 1 }))
|
||||
break
|
||||
}
|
||||
/* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */
|
||||
default: {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
}
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- every root frame produces one expression. */
|
||||
return result ?? 'Any'
|
||||
} catch {
|
||||
// An unsupported or malformed schema failed validation (before any
|
||||
// emission), or an unreachable internal invariant tripped. Either degrades
|
||||
// the node to `Any` rather than crashing prompt assembly — the Python
|
||||
// counterpart of the TS flavor's `unknown` fallback.
|
||||
state.typing.add('Any')
|
||||
return 'Any'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one JSON-Schema node to a context-free Python type expression from the
|
||||
* `typing` module. Handles every unified schema construct — `object` (degraded
|
||||
* to `dict[str, Any]`: naming a `TypedDict` requires the render context that
|
||||
* {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`),
|
||||
* `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array`
|
||||
* (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed
|
||||
* schema, matching the TS flavor's `unknown` fallback. Type annotations in the
|
||||
* emitted SDK are advisory: Python does not enforce them at runtime.
|
||||
* @param schema - the JSON-Schema node.
|
||||
* @returns the Python type text.
|
||||
*/
|
||||
export function jsonSchemaToPy(schema: unknown): string {
|
||||
// A throwaway state whose class collector never escapes: an object with
|
||||
// properties has nowhere to declare its TypedDict and degrades to
|
||||
// dict[str, Any]. renderToolsSdkPy drives the named-TypedDict path.
|
||||
return renderType(schema, '', { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set() })
|
||||
}
|
||||
|
||||
/** The fixed model-facing usage contract rendered above the declarations. */
|
||||
const SDK_INSTRUCTIONS = `## Writing code for run_code
|
||||
|
||||
Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program:
|
||||
|
||||
- Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue.
|
||||
- Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
|
||||
- Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. ONLY what you print and the returned value come back — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
The available tools:`
|
||||
|
||||
/**
|
||||
* Render the full `tools:sdk` prompt section under `runtime.language ===
|
||||
* 'python'`: the Python-flavored usage instructions plus one named `TypedDict`
|
||||
* per tool argument or output object (and per nested object) and one awaitable
|
||||
* method per visible tool on a `Tools` protocol — typed args in, the tool's
|
||||
* canonical output value out — with a `tools: Tools` singleton the model calls
|
||||
* into. The `typing` import line lists exactly the symbols the render used.
|
||||
* Deterministic — tools are emitted in lexicographic name order, and class
|
||||
* declarations precede the protocol in that same order (nested classes before
|
||||
* the parent that references them), so an unchanged tool set produces
|
||||
* byte-identical text across assemblies. The sort is not a total order on
|
||||
* byte-equal names, so two schemas sharing a name would render in argument
|
||||
* order; the caller's visible-capability map is keyed by name, so the input
|
||||
* never carries a duplicate.
|
||||
* @param schemas - the tool schemas plus canonical output schemas to declare
|
||||
* (the caller excludes `run_code` itself).
|
||||
* @returns the complete section text.
|
||||
*/
|
||||
export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string {
|
||||
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) }
|
||||
// ONE ordered member stream, matching the documented lexicographic contract
|
||||
// and the TypeScript flavor (which quotes exotic keys in place rather than
|
||||
// partitioning them out). Interleaving is free here: a comment line between
|
||||
// two `async def` lines is not a statement, so it changes nothing about how
|
||||
// the class body parses.
|
||||
const members: string[] = []
|
||||
let statements = 0
|
||||
for (const schema of sorted) {
|
||||
const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state)
|
||||
const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state)
|
||||
if (isBareIdentifier(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) {
|
||||
// A docstring only documents its method when it is the FIRST statement
|
||||
// of that method's body. Emitted before the `async def` it would instead
|
||||
// become the `Tools` class docstring (for the first tool) or a dead
|
||||
// expression (for every later one), leaving every method undocumented —
|
||||
// and under `mode: 'code'` this SDK is the model's only description of
|
||||
// what a tool does. A docstring is a complete body, so the `...` stub is
|
||||
// only for the description-less case.
|
||||
const doc = docLines(schema.description, 2)
|
||||
members.push(doc.length > 0
|
||||
? `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}:`
|
||||
: `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`)
|
||||
members.push(...doc)
|
||||
statements += 1
|
||||
} else {
|
||||
// Not reachable as ``tools.name`` — the model reaches it via
|
||||
// ``tools[name]``. Exotic names and hard keywords are not legal
|
||||
// attributes at all; an underscore-leading name (``_foo``) IS a legal
|
||||
// attribute and is routed here anyway, because the forms that break
|
||||
// split three ways — a non-dunder ``__token`` name-mangles at the CALL
|
||||
// site, a dunder that exists on ``object``/``type`` (``__class__``,
|
||||
// ``__doc__``) resolves before ``__getattr__`` ever runs, and implicit
|
||||
// special-method lookup skips the hook entirely — and one rule over the
|
||||
// whole family costs nothing while a per-form rule would have to
|
||||
// enumerate them (see {@link RESERVED}). The stub lists it as a subscript comment
|
||||
// (referencing the named TypedDicts too) so a reader sees what is
|
||||
// accessible; runtime resolution goes through the proxy's __getitem__.
|
||||
members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
|
||||
const description = describe(schema)
|
||||
if (description !== undefined) members.push(`${pad(1)}# ${description}`)
|
||||
}
|
||||
}
|
||||
// Subscript entries are COMMENTS, not statements: a class body of only
|
||||
// comments fails to parse, so `pass` is required whenever no method was
|
||||
// emitted — including the subscript-only tool set.
|
||||
const bodyLines = statements > 0 ? members : [`${pad(1)}pass`, ...members]
|
||||
const body = bodyLines.join('\n')
|
||||
const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol))
|
||||
const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : ''
|
||||
const errorDeclaration = 'class ToolCallError(Exception):\n toolName: str'
|
||||
const declaration = `from typing import ${imports.join(', ')}\n\n${errorDeclaration}\n\n${classBlock}class Tools(Protocol):\n${body}\n\ntools: Tools`
|
||||
return `${SDK_INSTRUCTIONS}\n\n\`\`\`python\n${declaration}\n\`\`\``
|
||||
}
|
||||
@@ -262,7 +262,10 @@ The available tools:`
|
||||
* Render the full `tools:sdk` prompt section: the fixed usage instructions
|
||||
* plus one `declare const tools` interface covering every given tool.
|
||||
* Deterministic — tools are emitted in lexicographic name order, so an
|
||||
* unchanged tool set produces byte-identical text across assemblies.
|
||||
* unchanged tool set produces byte-identical text across assemblies. The sort
|
||||
* is not a total order on byte-equal names, so two schemas sharing a name
|
||||
* would render in argument order; the caller's visible-capability map is keyed
|
||||
* by name, so the input never carries a duplicate.
|
||||
* @param schemas - the tool schemas to declare (the caller excludes
|
||||
* `run_code` itself).
|
||||
* @returns the complete section text.
|
||||
|
||||
@@ -335,9 +335,89 @@ describe('mode-aware wire contribution', () => {
|
||||
await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/)
|
||||
})
|
||||
|
||||
it("rejects every assembly when the runtime's language is not typescript", async () => {
|
||||
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
|
||||
await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/)
|
||||
it('rejects every assembly when the runtime language has no registered SDK renderer', async () => {
|
||||
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
|
||||
await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/)
|
||||
})
|
||||
|
||||
it('assembles under a python runtime by picking the Python SDK renderer', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
|
||||
expect(sdk?.text).toContain('class Tools(Protocol):')
|
||||
expect(sdk?.text).toContain('async def echo(self, args:')
|
||||
expect(sdk?.text).toContain('top-level `await`')
|
||||
})
|
||||
|
||||
it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => {
|
||||
// `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code
|
||||
// as `code`, so this pins the mode-by-language matrix rather than a
|
||||
// separate path — including that the `wireSchemas` projection behind
|
||||
// `assembly.tools` picks the Python flavor under `both` instead of hitting
|
||||
// the flavor-table guard.
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'both', runtime: { language: 'python' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('class Tools(Protocol):')
|
||||
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(runCodeSchema?.description).toContain('Execute a Python program')
|
||||
// `both` keeps the native tools alongside run_code; `code` does not.
|
||||
expect(assembly.tools.map(tool => tool.name)).toContain('echo')
|
||||
})
|
||||
|
||||
it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(runCodeSchema?.description).toContain('Execute a TypeScript program')
|
||||
expect(runCodeSchema?.description).toContain('BODY of an')
|
||||
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
|
||||
expect(codeParam.description).toBe('The program: the body of an async TypeScript function.')
|
||||
})
|
||||
|
||||
it('emits a Python-flavored run_code schema under a python runtime (matches the SDK language)', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
|
||||
registerEcho(ctx)
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(runCodeSchema?.description).toContain('Execute a Python program')
|
||||
expect(runCodeSchema?.description).toContain('`return <value>`')
|
||||
expect(runCodeSchema?.description).not.toContain('TypeScript')
|
||||
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
|
||||
expect(codeParam.description).toBe('The program: the body of an async Python function.')
|
||||
})
|
||||
|
||||
it('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => {
|
||||
// The flavor getter reads the runtime directly (peekRuntime), so it — not
|
||||
// requireCodeRuntime — owns the flavor-table guard. Keeping
|
||||
// RUN_CODE_FLAVORS in step with SDK_RENDERERS is the compiler's job (both
|
||||
// are `satisfies`-checked against CodeSdkLanguage), so what the guard
|
||||
// covers is a mounted runtime naming a language absent from both tables,
|
||||
// which throws when the schema is projected. Assembly's
|
||||
// requireCodeRuntime rejects such a language earlier; this reaches the
|
||||
// guard on its own.
|
||||
const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
|
||||
const definition = ctx.tools.get(RUN_CODE_NAME)
|
||||
// Names the known languages, symmetric with the SDK_RENDERERS guard: this
|
||||
// is the reachable rejection, so it must be at least as diagnosable.
|
||||
expect(() => definition?.description)
|
||||
.toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/)
|
||||
})
|
||||
|
||||
it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => {
|
||||
// Any reader of the definition without a mounted runtime lands here; the
|
||||
// shipped one is the tool-catalog generator, which boots the registry under
|
||||
// `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime
|
||||
// returns undefined there, so the flavor getter degrades to the TS default
|
||||
// rather than throwing. None of those readers feeds a model: assembly goes
|
||||
// through wireSchemas, which requires a runtime first.
|
||||
const { ctx } = await setup({ mode: 'code', runtime: false })
|
||||
const definition = ctx.tools.get(RUN_CODE_NAME)
|
||||
expect(definition?.description).toContain('Execute a TypeScript program')
|
||||
const params = definition?.parameters as { properties: { code: { description: string } } }
|
||||
expect(params.properties.code.description).toBe('The program: the body of an async TypeScript function.')
|
||||
})
|
||||
|
||||
it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => {
|
||||
|
||||
1163
packages/core/tools/tests/py-types.spec.ts
Normal file
1163
packages/core/tools/tests/py-types.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -527,6 +527,28 @@ function detachedProjectionsFor(
|
||||
return registry.restore({}, events, 0).snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort projections for one subagent history page, fail-soft like
|
||||
* {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
|
||||
* never blocks transcript reading — the page is served without the block.
|
||||
* @param ctx - context carrying the logger for the degradation warning.
|
||||
* @param childSessionId - the child whose page is being decorated.
|
||||
* @param compute - the arm-specific fold (live watermark or detached restore).
|
||||
* @returns the projections block, or undefined when the fold failed.
|
||||
*/
|
||||
function subagentHistoryProjections(
|
||||
ctx: Context,
|
||||
childSessionId: SessionId,
|
||||
compute: () => SessionProjectionsBlock | undefined,
|
||||
): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
return compute()
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Map continuation admission failures without exposing provider details. */
|
||||
function subagentPromptError(
|
||||
request: RpcRequest<{ childSessionId: SessionId }>,
|
||||
@@ -567,6 +589,15 @@ function subagentPromptError(
|
||||
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
|
||||
}
|
||||
|
||||
/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
|
||||
function projectionsUnavailableError(): RpcError {
|
||||
return {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
details: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify one address and mode against the complete direct-child catalog. */
|
||||
async function catalogChild(
|
||||
ctx: Context,
|
||||
@@ -600,19 +631,11 @@ async function catalogChild(
|
||||
}
|
||||
return { entry }
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
|
||||
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
|
||||
}
|
||||
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return {
|
||||
error: {
|
||||
code: 'subagent-not-found',
|
||||
message: `parent session "${parentSessionId}" was not found`,
|
||||
details: { parentSessionId, childSessionId },
|
||||
},
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return { error: projectionsUnavailableError() }
|
||||
}
|
||||
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
|
||||
}
|
||||
@@ -925,28 +948,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the session's own suffix carries the durable subagent discriminator. */
|
||||
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
|
||||
const events = session.events
|
||||
// Indexed scan from the own-suffix start: slicing copies the whole suffix
|
||||
// on every Agent-bound RPC, including each `session.prompt` on long
|
||||
// transcripts.
|
||||
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
|
||||
if (events[index]?.type === 'subagent/descriptor') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Host interaction cannot claim a durably classified subagent or an
|
||||
* Agent created through its live parent. The runtime-owner arm also covers
|
||||
* descriptor-less child publication windows and older stored headers.
|
||||
* Generic Host interaction cannot claim a durably classified subagent
|
||||
* (`origin: 'subagent'` in the header) or an Agent runtime-owned by its
|
||||
* live parent.
|
||||
*/
|
||||
function hasSubagentOwner(
|
||||
session: Pick<Session, 'events' | 'header'>,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
@@ -1002,7 +1013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectServable(sessionId)
|
||||
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
||||
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
@@ -1121,7 +1132,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Ownership first: explicit-id adoption of a session-backed
|
||||
// subagent must answer `agent-busy` regardless of the requested
|
||||
// cwd (the api/commands.ts contract), not a cwd conflict.
|
||||
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
||||
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
if (inspected.meta.cwd !== cwd) {
|
||||
@@ -1903,15 +1914,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent catalog read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return err(request, projectionsUnavailableError())
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog read failed',
|
||||
@@ -1928,44 +1940,65 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
parentSessionId, childSessionId, mode,
|
||||
}, signal)
|
||||
if (verified.error !== undefined) return err(request, verified.error)
|
||||
try {
|
||||
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
|
||||
signal?.throwIfAborted()
|
||||
if (snapshot.session.parentSession !== parentSessionId) {
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent parent changed during history read',
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
|
||||
const projections = beforeSeq === undefined
|
||||
? detachedProjectionsFor(ctx, snapshot.events)
|
||||
// The generic-history data plane: an attached child serves its
|
||||
// in-memory snapshot and the registry's live watermark projections; a
|
||||
// cold child is one persistence inspection plus a detached fold.
|
||||
let header: SessionHeader
|
||||
let events: SessionEvent[]
|
||||
let projections: SessionProjectionsBlock | undefined
|
||||
const attached = ctx.sessions.get(childSessionId)
|
||||
if (attached !== undefined) {
|
||||
header = attached.header
|
||||
events = [...attached.events]
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
|
||||
: undefined
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
} else {
|
||||
try {
|
||||
const inspected = await inspectServable(childSessionId)
|
||||
header = inspected.meta
|
||||
events = inspected.events
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
|
||||
: undefined
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionNotFound) {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
code: 'internal',
|
||||
message: 'subagent history read failed',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent history read failed',
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (header.parentSession !== parentSessionId) {
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent parent changed during history read',
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, events, beforeSeq, maxMessages)
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
|
||||
async prompt(request, signal) {
|
||||
|
||||
@@ -70,7 +70,8 @@ export interface SubagentsApi {
|
||||
): Promise<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* Reads one healthy catalog child's persisted raw log with ordinary
|
||||
* Reads one healthy catalog child's transcript — the in-memory snapshot of
|
||||
* a live child, the persisted log of a cold one — with ordinary
|
||||
* message-aligned pagination and render intents, without Agent activation.
|
||||
*/
|
||||
history(
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('subagent ownership fence', () => {
|
||||
const meta = header('session-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
seedLength: 0,
|
||||
origin: 'subagent',
|
||||
})
|
||||
const events = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
@@ -245,6 +246,47 @@ describe('subagent ownership fence', () => {
|
||||
expect(inspect).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-legacy-child')
|
||||
const meta = header('session-legacy-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
seedLength: 0,
|
||||
})
|
||||
const events = [
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
|
||||
},
|
||||
] as SessionEvent[]
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
// Pre-#1569 stores classify a child only through the descriptor event and
|
||||
// carry no header `origin`; the pre-release decision stops recognizing
|
||||
// them, so the ownership fence lets generic resume reach the registry
|
||||
// instead of answering `agent-busy`.
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
.mockRejectedValue(new Error('registry unavailable in this bench'))
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const prompt = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'follow up' }],
|
||||
}))
|
||||
expect(resume).toHaveBeenCalledTimes(1)
|
||||
expect(prompt.result.ok).toBe(false)
|
||||
if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal')
|
||||
})
|
||||
|
||||
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import type { RpcRequest } from '../src/api/rpc.ts'
|
||||
@@ -21,7 +20,12 @@ function bench(options: {
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
listError?: Error
|
||||
readError?: Error
|
||||
/** Persistence forgets the child entirely (the vanished-mid-read race). */
|
||||
storedChild?: false
|
||||
/** Attach the child to the live session store instead of persistence only. */
|
||||
liveChild?: true
|
||||
/** Every registered projection unit throws on this child's payloads. */
|
||||
projectionsThrow?: true
|
||||
historyParent?: SessionId
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
@@ -49,25 +53,44 @@ function bench(options: {
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
const readSession = vi.fn(() => options.readError === undefined
|
||||
? Promise.resolve({
|
||||
session: {
|
||||
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader,
|
||||
events: [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
|
||||
] as unknown as SessionEvent[],
|
||||
})
|
||||
: Promise.reject(options.readError))
|
||||
const childHeader = {
|
||||
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader
|
||||
const childEvents = [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
|
||||
] as unknown as SessionEvent[]
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents }))
|
||||
const liveBlock = { values: {}, asOfSeq: 3 }
|
||||
const coldBlock = { values: {}, asOfSeq: 0 }
|
||||
const snapshot = vi.fn(() => {
|
||||
if (options.projectionsThrow === true) throw new Error('hostile unit')
|
||||
return liveBlock
|
||||
})
|
||||
const restore = vi.fn(() => {
|
||||
if (options.projectionsThrow === true) throw new Error('hostile unit')
|
||||
return { snapshot: coldBlock }
|
||||
})
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
ctx.provide('sessionQuery', { readSession })
|
||||
ctx.provide('sessions', {
|
||||
get: (id: SessionId) => options.liveChild === true && id === CHILD
|
||||
? { id: CHILD, header: childHeader, events: childEvents }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
})
|
||||
// The gateway's own projection push feed subscribes at construction; the
|
||||
// no-op disposer keeps that seam quiet while these tests pin history reads.
|
||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, readSession, followup, parent }
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
@@ -113,7 +136,7 @@ describe('subagent gateway', () => {
|
||||
})
|
||||
|
||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
||||
const { api, getAgent, readSession } = bench()
|
||||
const { api, getAgent, inspect, restore } = bench()
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
|
||||
}))
|
||||
@@ -121,27 +144,65 @@ describe('subagent gateway', () => {
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
expect(readSession).toHaveBeenCalledWith(CHILD)
|
||||
expect(inspect).toHaveBeenCalledWith(CHILD)
|
||||
expect(restore).toHaveBeenCalledTimes(1)
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves a live child from the in-memory snapshot and the watermark projections', async () => {
|
||||
const { api, inspect, snapshot, restore } = bench({ liveChild: true })
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, projections: { asOfSeq: 3 } },
|
||||
})
|
||||
expect(snapshot).toHaveBeenCalledTimes(1)
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves the page without projections when a hostile unit breaks the fold', async () => {
|
||||
const cold = bench({ projectionsThrow: true })
|
||||
const coldResponse = await cold.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(coldResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false)
|
||||
|
||||
const live = bench({ projectionsThrow: true, liveChild: true })
|
||||
const liveResponse = await live.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(liveResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false)
|
||||
expect(live.snapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
||||
const oneShot = {
|
||||
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}
|
||||
const { api, readSession } = bench({ entries: [oneShot] })
|
||||
const { api, inspect } = bench({ entries: [oneShot] })
|
||||
expect((await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
|
||||
}))).result).toMatchObject({ ok: true })
|
||||
expect((await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
|
||||
expect(readSession).toHaveBeenCalledTimes(1)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a diagnostic address before reading history', async () => {
|
||||
const { api, readSession } = bench({ entries: [
|
||||
const { api, inspect } = bench({ entries: [
|
||||
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
|
||||
] })
|
||||
const response = await api.subagents.history(request({
|
||||
@@ -154,7 +215,34 @@ describe('subagent gateway', () => {
|
||||
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
|
||||
},
|
||||
})
|
||||
expect(readSession).not.toHaveBeenCalled()
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps the missing projections capability to one wire face on list, history, and prompt', async () => {
|
||||
const listError = () => new SubagentError(
|
||||
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
|
||||
)
|
||||
const expected = {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
}
|
||||
|
||||
const list = bench({ listError: listError() })
|
||||
expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: false, error: expected })
|
||||
|
||||
const history = bench({ listError: listError() })
|
||||
expect((await history.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({ ok: false, error: expected })
|
||||
expect(history.inspect).not.toHaveBeenCalled()
|
||||
|
||||
const prompt = bench({ listError: listError() })
|
||||
expect((await prompt.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
|
||||
expect(prompt.followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes human content through the exact live parent with rpc attribution', async () => {
|
||||
@@ -193,9 +281,7 @@ describe('subagent gateway', () => {
|
||||
})
|
||||
|
||||
it('maps history disappearance and hides unexpected backend details', async () => {
|
||||
const disappeared = bench({
|
||||
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
|
||||
})
|
||||
const disappeared = bench({ storedChild: false })
|
||||
expect((await disappeared.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({
|
||||
|
||||
@@ -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/llm/llm-deepseek/README.md
|
||||
README.md: 0cd265cadb2b2a619613761062ab2cef209bec83
|
||||
README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f
|
||||
README.md: b583ecadf23ec4d089bfc9473dc1165c3e70ae9a
|
||||
README.zh.md: 42d38e913b98b9ed2cf1781fdc6f716a0050c905
|
||||
|
||||
@@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
|
||||
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
|
||||
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
|
||||
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
|
||||
|
||||
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
|
||||
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
|
||||
|
||||
|
||||
@@ -58,7 +58,10 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
* reasoning effort resolves to `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
/**
|
||||
* Trimmed literal API key; whitespace-only is absent. Prefer
|
||||
* {@link apiKeyEnv} to keep secrets out of configuration files.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
@@ -153,6 +156,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
* @returns validated connection facts plus the credential reference.
|
||||
*/
|
||||
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
const apiKey = config.apiKey?.trim()
|
||||
if (config.thinking === 'disabled'
|
||||
&& config.reasoningEffort !== undefined
|
||||
&& config.reasoningEffort !== 'off') {
|
||||
@@ -175,7 +179,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
)
|
||||
}
|
||||
return {
|
||||
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
|
||||
...apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {},
|
||||
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
||||
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
|
||||
defaults: {
|
||||
|
||||
@@ -699,6 +699,13 @@ describe('plugin registration and config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a literal API key and treats whitespace as absent', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
|
||||
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(whitespace.apiKey).toBeUndefined()
|
||||
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Config as CodexHooksConfig } from '@deepseek-ai/dsh-hooks-codex'
|
||||
import type { Config as JsonlConfig } from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import type { Config as SqliteConfig } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import type { Config as ToolSubagentConfig } from '@deepseek-ai/dsh-tool-subagent'
|
||||
import type { Config as ToolTodoConfig } from '@deepseek-ai/dsh-tool-todo'
|
||||
import type { Config as ToolWebConfig } from '@deepseek-ai/dsh-tool-web'
|
||||
import type { ProjectProfile } from '../../project/types.ts'
|
||||
import { defineFeatures } from '../define-feature.ts'
|
||||
@@ -126,7 +127,12 @@ config:
|
||||
id: 'default',
|
||||
label: 'todo_write tool',
|
||||
default: true,
|
||||
resources: [{ kind: 'npm-cordis-config-entry', id: 'tool-todo', package: '@deepseek-ai/dsh-tool-todo' }],
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-todo',
|
||||
package: '@deepseek-ai/dsh-tool-todo',
|
||||
config: { allowParallelInProgress: true } satisfies ToolTodoConfig,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
{
|
||||
"path": "../../subagent/tool-subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../web/tool-web"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
|
||||
type SessionEventSurface,
|
||||
@@ -1114,6 +1114,24 @@ describe('session-query exact reads', () => {
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
|
||||
it('wraps persisted corruption as SESSION_QUERY_CORRUPT_SESSION with its cause preserved', async () => {
|
||||
const durable = header('durable-corrupt')
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const corruption = new SessionPersistenceCorruptionError(
|
||||
'stored prefix failed validation',
|
||||
{ cause: new Error('torn final record') },
|
||||
)
|
||||
TestPersistence.inspectFailure = corruption
|
||||
|
||||
await expect(ctx.sessionQuery.readSession(durable.id)).rejects.toMatchObject({
|
||||
code: 'SESSION_QUERY_CORRUPT_SESSION',
|
||||
message: `stored session "${durable.id}" is corrupt: stored prefix failed validation`,
|
||||
cause: corruption,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => {
|
||||
const durable = header('durable')
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog() }])
|
||||
|
||||
@@ -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/subagent/subagent/README.md
|
||||
README.md: 08b6175e018db072b99490a25b8df887bb89eb47
|
||||
README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3
|
||||
README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829
|
||||
README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902
|
||||
|
||||
@@ -21,7 +21,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci
|
||||
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
|
||||
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
|
||||
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. |
|
||||
|
||||
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
|
||||
@@ -78,13 +78,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata.
|
||||
When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Each healthy row derives its read-time `hasChildren` hint from traced direct-descendant headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
|
||||
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按 `createdAt` 再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。直接读取在线会话存储与可选的会话持久化(持久化缺席时仅枚举在线 child),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 |
|
||||
|
||||
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。
|
||||
|
||||
@@ -78,13 +78,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个健康条目都会根据追踪结果中携带持久化 `origin: 'subagent'` 的直接后代 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
|
||||
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -50,10 +50,10 @@
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"@deepseek-ai/dsh-session-projection-cache": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
@@ -68,8 +68,10 @@
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
* @module @deepseek-ai/dsh-subagent/client
|
||||
*/
|
||||
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
* continuation manager holds their `AgentHandle` directly and orders every turn
|
||||
* through the child's own inbox, so providers contribute only the detached
|
||||
* creation spec and see no handle, turn, or teardown. Direct-child discovery
|
||||
* independently interprets the optional session-query corpus and does not
|
||||
* require that continuation runtime.
|
||||
* reads the live session store and optional session persistence directly and
|
||||
* does not require that continuation runtime.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
@@ -65,7 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import { subagentTimingProjectionDefinition } from './projection.ts'
|
||||
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -118,7 +118,7 @@ export type {
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -190,6 +190,7 @@ export class SubagentService extends Service {
|
||||
})
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
|
||||
projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -283,22 +284,32 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the parent's direct session-backed subagents from the
|
||||
* live-preferred session corpus without loading or resuming an Agent. Session
|
||||
* query supplies lineage, candidate order, event reads, and live state; this
|
||||
* service interprets descriptor mode, activity, and per-child diagnostics
|
||||
* without consulting Agent registrations, Activations, or providers.
|
||||
* Enumerate the parent's direct session-backed subagents without loading or
|
||||
* resuming an Agent and without any query seam: the listing merges the live
|
||||
* session store with optional session persistence (live-preferred) and
|
||||
* serves each child's durable mode/label from the registered `subagent`
|
||||
* projection unit down a three-rung ladder — the registry's watermark
|
||||
* snapshot for a live child; for a cold one, a durable projection-cache
|
||||
* row when the optional cache serves an own-suffix identity (its `seq`
|
||||
* gate proves the value postdates the fork seed, where a child's own
|
||||
* descriptor is immutable once appended), else one persistence inspection
|
||||
* folded through the registry. The
|
||||
* projection fold is the single classification authority; per-child
|
||||
* diagnostics relay a fold that served no identity or a failed inspection,
|
||||
* never a list-time descriptor parse. Absent persistence, enumeration is
|
||||
* live-only (a cold child cannot be resumed then either, so its absence is
|
||||
* capability absence, not an error). This service consults no Agent
|
||||
* registrations, Activations, or providers.
|
||||
*
|
||||
* The trace and exact descriptor read receive `signal`; the full event-list
|
||||
* read has no signal parameter, so the scan rechecks cancellation around
|
||||
* every await and between candidates. Query rejections that settle after an
|
||||
* abort become a stable `SubagentError` with code `CANCELLED`.
|
||||
* Every persistence read receives `signal`, and the listing rechecks
|
||||
* cancellation around each of those awaits. Read rejections that settle
|
||||
* after an abort become a stable `SubagentError` with code `CANCELLED`.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation forwarded where supported and
|
||||
* observed around every query await.
|
||||
* @returns children and per-child diagnostics in stable trace order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or the
|
||||
* caller cancels the scan.
|
||||
* @param signal - caller-owned cancellation forwarded to persistence reads
|
||||
* and observed around every read await.
|
||||
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
|
||||
* @throws {@link SubagentError} when the projection registry or the session
|
||||
* store is not mounted, or the caller cancels the listing.
|
||||
*/
|
||||
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
|
||||
return listSubagentChildren(this.ctx, parentSessionId, signal)
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
/**
|
||||
* Read-only interpretation of session-query lineage as durable subagent
|
||||
* children. Only descendants with durable `origin: 'subagent'` enter per-child
|
||||
* inspection. The module owns no catalog state and does not consult Activation,
|
||||
* Agent-registry, continuation-manager, or provider state. A child's descriptor
|
||||
* distinguishes one-shot work from a continuable conversation.
|
||||
* Read-only enumeration of one parent's durable subagent children straight
|
||||
* from the live session store and optional session persistence — no query
|
||||
* seam. Candidates are the live-preferred merge of both listings filtered to
|
||||
* durable `origin: 'subagent'` under the parent; each child's mode/label is
|
||||
* the registered `subagent` projection unit's value, resolved down a
|
||||
* three-rung ladder: the registry's watermark cache for a live child, a
|
||||
* durable projection-cache row when it serves an own-suffix identity (the
|
||||
* seq gate), and one persistence inspection folded through the registry
|
||||
* otherwise, validated against the enumerated lifecycle. The projection
|
||||
* fold is the single
|
||||
* classification authority — this module parses no descriptor itself. Absent
|
||||
* persistence, enumeration is live-only: a cold child is unreachable for
|
||||
* resume anyway, so its absence is capability absence, not an error. The
|
||||
* module owns no catalog state and does not consult Activation,
|
||||
* Agent-registry, continuation-manager, or provider state.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type SubagentService from './index.ts'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SubagentError } from './error.ts'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
|
||||
type SessionQueryRuntime = Pick<
|
||||
typeof import('@deepseek-ai/dsh-session-query'),
|
||||
'assertSessionHeadersCompatible' | 'SessionQueryError'
|
||||
>
|
||||
import type { SubagentIdentityProjection } from './projection-types.ts'
|
||||
|
||||
/**
|
||||
* One entry of a {@link listChildren} result in trace candidate order. Only a
|
||||
* candidate whose durable header has `origin: 'subagent'` is inspected. A
|
||||
* valid descriptor produces a `child`, a per-child inspection failure produces
|
||||
* a `diagnostic`, and a candidate without its own descriptor is omitted.
|
||||
* Healthy rows include a one-level, origin-classified descendant hint.
|
||||
* Diagnostics are transient query results, never session events or catalog
|
||||
* state, and never expose model-hidden descriptor content.
|
||||
* Concurrent cold inspections per listing; a constant because it bounds one
|
||||
* read-only scan of local media, not deployment behavior. Should a networked
|
||||
* persistence backend appear, promote it to a validated `Config` field.
|
||||
*/
|
||||
const COLD_READ_CONCURRENCY = 4
|
||||
|
||||
/**
|
||||
* One entry of a {@link listChildren} result, ordered by header `createdAt`
|
||||
* with ties broken on id. Only a candidate whose durable header has
|
||||
* `origin: 'subagent'` is interpreted. A served `subagent` projection value
|
||||
* produces a `child`; a settled candidate whose fold served no identity
|
||||
* produces a `diagnostic`; a running candidate without one is omitted — its
|
||||
* descriptor may not be appended yet (the creation window). Diagnostics
|
||||
* relay the projection fold's outcome or a failed read, never a per-child
|
||||
* event scan, and never expose model-hidden descriptor content.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
@@ -35,7 +49,7 @@ export type SubagentListEntry =
|
||||
/** The durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Corpus snapshot activity: `running` means the logical record is live in
|
||||
* Store snapshot activity: `running` means the logical record is live in
|
||||
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
|
||||
* encodes a durable outcome, and a continuable child may still reject
|
||||
* delivery as an ownership conflict.
|
||||
@@ -59,179 +73,263 @@ export type SubagentListEntry =
|
||||
)
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** The traced candidate's session id. */
|
||||
/** The candidate's session id. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Why the candidate was omitted: `corrupt` for invalid surfaces, header
|
||||
* conflicts, or malformed/duplicated descriptors; `unsupported` for an
|
||||
* unknown descriptor version; `unavailable` when the child disappeared or
|
||||
* its per-child read hit a persistence failure.
|
||||
* Why the candidate has no `child` row: `corrupt` for a settled candidate
|
||||
* whose projection fold served no identity (a missing, malformed, or
|
||||
* unrecognized-version descriptor — deliberately undistinguished), and
|
||||
* for any candidate whose log makes a registered unit's fold or schema
|
||||
* throw (deterministic data damage, contained per child); `unavailable`
|
||||
* when the candidate's persistence inspection failed (retried on the
|
||||
* next listing). `unsupported` is kept for consumers already routing on
|
||||
* it but is no longer produced.
|
||||
*/
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one parent's origin-classified direct descendants as session-backed
|
||||
* subagents without loading or resuming an Agent. Ordinary forks are skipped
|
||||
* before per-child event inspection.
|
||||
* @see {@link SubagentService.listChildren} for the public cancellation and
|
||||
* failure contract.
|
||||
* @param ctx - context carrying the optional session-query service.
|
||||
* Enumerate one parent's origin-classified direct children from the
|
||||
* live-preferred merge of `ctx.sessions` and optional session persistence,
|
||||
* serving each identity from the `subagent` projection unit: the registry's
|
||||
* watermark snapshot for a live child; for a cold one, a durable
|
||||
* projection-cache row when it serves an own-suffix identity (the seq gate),
|
||||
* else one bounded-concurrency persistence inspection folded through the
|
||||
* registry.
|
||||
* @see SubagentService.listChildren for the public cancellation and failure contract.
|
||||
* @param ctx - context carrying the session store, the projection registry,
|
||||
* optional persistence, and the optional projection cache.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation.
|
||||
* @returns children and per-child diagnostics in stable trace order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or
|
||||
* the caller cancels the scan.
|
||||
* @param signal - caller-owned cancellation observed around every persistence read.
|
||||
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
|
||||
* @throws {@link SubagentError} when the projection registry or the session
|
||||
* store is not mounted, or the caller cancels the listing.
|
||||
*/
|
||||
export async function listChildren(
|
||||
ctx: Context,
|
||||
parentSessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<SubagentService['listChildren']> {
|
||||
const query = ctx.get('sessionQuery')
|
||||
if (query === undefined) {
|
||||
): Promise<SubagentListEntry[]> {
|
||||
const projections = ctx.get('sessionProjections')
|
||||
// Checked before any read, even with zero candidates: mode/label are the
|
||||
// row's strong contract, so a missing fold capability is a deterministic
|
||||
// deployment configuration error, never an empty success.
|
||||
if (projections === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires session query (load a dsh-session-query backend)',
|
||||
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
|
||||
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
// Strict global read, never the `ctx.sessions` property proxy: the proxy is
|
||||
// caller-scope bound, so a consumer plugin without its own `sessions`
|
||||
// injection (the model-facing tool, the API proxy) would throw on access.
|
||||
const sessions = ctx.get('sessions')
|
||||
if (sessions === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires the session store (load @deepseek-ai/dsh-session)',
|
||||
'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
// Keep runtime values behind the listing-only boundary so ordinary
|
||||
// subagent imports and control operations do not evaluate the optional peer.
|
||||
const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query')
|
||||
assertListingNotCancelled(signal)
|
||||
const trace = await runListingQuery(
|
||||
() => query.traceSession(parentSessionId, signal),
|
||||
signal,
|
||||
)
|
||||
const entries: SubagentListEntry[] = []
|
||||
for (const node of trace.descendants) {
|
||||
if (node.session.header.origin !== 'subagent') continue
|
||||
const hasChildren = node.descendants.some(
|
||||
descendant => descendant.session.header.origin === 'subagent',
|
||||
)
|
||||
const entry = await inspectChild(
|
||||
query, queryRuntime, parentSessionId, node.session, hasChildren, signal,
|
||||
)
|
||||
// Cancellation can race the inspection's last checkpoint or diagnostic
|
||||
// mapping; do not return success or begin another candidate afterward.
|
||||
assertListingNotCancelled(signal)
|
||||
if (entry !== undefined) entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */
|
||||
async function inspectChild(
|
||||
query: SessionQueryService,
|
||||
queryRuntime: SessionQueryRuntime,
|
||||
parentSessionId: SessionId,
|
||||
candidate: SessionRecord,
|
||||
hasChildren: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SubagentListEntry | undefined> {
|
||||
const childId = candidate.header.id
|
||||
try {
|
||||
const records = await runListingQuery(() => query.listEvents(childId), signal)
|
||||
// Only the child's own suffix: a fork seed may replay an ancestor's
|
||||
// descriptor without making the fork itself a subagent.
|
||||
const seedLength = candidate.header.seedLength ?? 0
|
||||
const descriptorSeqs = records
|
||||
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
|
||||
.map(record => record.seq)
|
||||
if (descriptorSeqs.length === 0) return undefined
|
||||
if (descriptorSeqs.length > 1) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this exact-read sequence exists.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await runListingQuery(
|
||||
() => query.readEvent({ sessionId: childId, seq }, signal),
|
||||
signal,
|
||||
)
|
||||
queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header)
|
||||
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let descriptor: ReturnType<typeof foldSubagentDescriptor>
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
// Optional acceleration only: an absent cache service just means every
|
||||
// cold candidate takes the authoritative preparation rung, so it carries
|
||||
// no error code and no configuration check.
|
||||
const cache = ctx.get('sessionProjectionCache')
|
||||
let persistedHeaders: readonly SessionHeader[] = []
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([window.target])
|
||||
} catch {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
persistedHeaders = await persistence.list(signal)
|
||||
} catch (error: unknown) {
|
||||
// The backend may reject with its own abort failure after observing the
|
||||
// forwarded signal; cancellation stays a stable subagent failure.
|
||||
assertListingNotCancelled(signal)
|
||||
throw error
|
||||
}
|
||||
if (descriptor === undefined) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
|
||||
}
|
||||
const activity = candidate.live ? 'running' : 'inactive'
|
||||
if (descriptor.mode === 'one-shot') {
|
||||
return {
|
||||
kind: 'child',
|
||||
id: childId,
|
||||
mode: descriptor.mode,
|
||||
...descriptor.label !== undefined ? { label: descriptor.label } : {},
|
||||
activity,
|
||||
hasChildren,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label,
|
||||
activity, hasChildren,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
|
||||
if (reason === undefined) throw error
|
||||
return { kind: 'diagnostic', id: childId, reason }
|
||||
assertListingNotCancelled(signal)
|
||||
}
|
||||
// Live-preferred merge without header reconciliation: a live record wins
|
||||
// its id wholesale, exactly as a live-preferred corpus would serve it.
|
||||
const corpus = new Map<SessionId, { header: SessionHeader; live: Session | undefined }>()
|
||||
for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined })
|
||||
for (const session of sessions.list()) {
|
||||
corpus.set(session.header.id, { header: session.header, live: session })
|
||||
}
|
||||
const subagentParents = new Set<SessionId>()
|
||||
for (const record of corpus.values()) {
|
||||
if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) {
|
||||
subagentParents.add(record.header.parentSession)
|
||||
}
|
||||
}
|
||||
const candidates = [...corpus.values()]
|
||||
.filter(record => record.header.parentSession === parentSessionId
|
||||
&& record.header.origin === 'subagent')
|
||||
.sort((a, b) => a.header.createdAt - b.header.createdAt
|
||||
|| a.header.id.localeCompare(b.header.id))
|
||||
|
||||
const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
|
||||
const coldReads: { index: number; header: SessionHeader }[] = []
|
||||
candidates.forEach((candidate, index) => {
|
||||
const childId = candidate.header.id
|
||||
if (candidate.live === undefined) {
|
||||
coldReads.push({ index, header: candidate.header })
|
||||
return
|
||||
}
|
||||
// The registry's watermark cache serves the live value with zero log
|
||||
// reads; a live child without an identity yet is the creation window
|
||||
// before the establishing provider appends its descriptor.
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.snapshot(candidate.live).values.subagent
|
||||
} catch {
|
||||
// The snapshot folds EVERY registered unit over this child's log, so
|
||||
// any unit's fold or schema can reject damaged payloads. That is
|
||||
// deterministic data damage in this one child; it degrades to one
|
||||
// corrupt diagnostic instead of failing the whole listing.
|
||||
rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
return
|
||||
}
|
||||
// The unit's serializable no-value sentinel is `null`; `undefined` can
|
||||
// only mean the key was dropped at a JSON boundary. Both are no value.
|
||||
if (identity === undefined || identity === null) return
|
||||
rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId))
|
||||
})
|
||||
|
||||
// Cold candidates exist only when persistence listed them, so the narrow
|
||||
// re-check is about types, not reachability.
|
||||
if (persistence !== undefined && coldReads.length > 0) {
|
||||
const queue = [...coldReads]
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
|
||||
async () => {
|
||||
for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
|
||||
rows[job.index] = await resolveColdIdentity(
|
||||
persistence, projections, cache, job.header,
|
||||
subagentParents.has(job.header.id), signal,
|
||||
)
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
return rows.filter((row): row is SubagentListEntry => row !== undefined)
|
||||
}
|
||||
|
||||
/** Stop a listing scan at its next cancellation checkpoint. */
|
||||
/**
|
||||
* Resolve one cold candidate down the remaining ladder: a durable
|
||||
* projection-cache row when it serves an own-suffix identity (the seq gate),
|
||||
* otherwise one persistence inspection folded through the projection
|
||||
* registry (the same detached recipe the API proxy uses for detached session
|
||||
* projections). A failed inspection is one transient `unavailable` row
|
||||
* retried on the next listing; an inspection naming another lifecycle, and a
|
||||
* settled log the fold cannot identify — or that makes any registered unit
|
||||
* throw — are final, so they report `corrupt`.
|
||||
*/
|
||||
async function resolveColdIdentity(
|
||||
persistence: SessionPersistence,
|
||||
projections: SessionProjectionRegistry,
|
||||
cache: SessionProjectionCache | undefined,
|
||||
header: SessionHeader,
|
||||
hasChildren: boolean,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<SubagentListEntry> {
|
||||
const childId = header.id
|
||||
if (cache !== undefined) {
|
||||
let cached: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
cached = cache.cachedSnapshot(header)?.values.subagent
|
||||
} catch {
|
||||
// Unlike the preparation fold below, a throwing cache read renders no
|
||||
// verdict: the cache is derived data, so its damage (a poisoned stored
|
||||
// row of ANY unit) silently falls through to the authoritative re-fold.
|
||||
cached = undefined
|
||||
}
|
||||
// A child's OWN descriptor is immutable once appended, so a cached
|
||||
// identity is final only when the seq gate proves it was folded from the
|
||||
// own suffix: a creation-window checkpoint may instead carry a fork
|
||||
// seed's replayed ANCESTOR descriptor (seq below `seedLength`), which
|
||||
// must not outrank the re-fold. Everything else also falls through to
|
||||
// preparation: an absent key (a cut before any descriptor) and the
|
||||
// `null` sentinel, whose verdict belongs to the authoritative re-fold,
|
||||
// not to a derived row.
|
||||
if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) {
|
||||
return childRow(childId, cached, 'inactive', hasChildren)
|
||||
}
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
let inspected: { meta: SessionHeader; events: readonly SessionEvent[] }
|
||||
try {
|
||||
inspected = await persistence.inspect(childId, signal)
|
||||
} catch {
|
||||
// Per-child isolation: the child vanished or its backend read failed —
|
||||
// one diagnostic row, and the listing itself still succeeds.
|
||||
assertListingNotCancelled(signal)
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
// A session id names a slot, not a lifecycle: a child deleted and
|
||||
// re-published under another owner between the enumeration and this read
|
||||
// must not leak into the old parent's listing.
|
||||
if (!sameLifecycle(inspected.meta, header)) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
|
||||
} catch {
|
||||
// The restore folds EVERY registered unit over this child's log, so any
|
||||
// unit's fold or schema can reject damaged payloads — deterministic data
|
||||
// damage in this one child, contained as its own corrupt diagnostic.
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
if (identity === undefined || identity === null) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
return childRow(childId, identity, 'inactive', hasChildren)
|
||||
}
|
||||
|
||||
/** Materialize one served identity as its child row. */
|
||||
function childRow(
|
||||
id: SessionId,
|
||||
identity: SubagentIdentityProjection,
|
||||
activity: 'running' | 'inactive',
|
||||
hasChildren: boolean,
|
||||
): SubagentListEntry {
|
||||
return identity.mode === 'one-shot'
|
||||
? {
|
||||
kind: 'child',
|
||||
id,
|
||||
mode: 'one-shot',
|
||||
...identity.label !== undefined ? { label: identity.label } : {},
|
||||
activity,
|
||||
hasChildren,
|
||||
}
|
||||
: {
|
||||
kind: 'child',
|
||||
id,
|
||||
mode: 'continuable',
|
||||
label: identity.label,
|
||||
activity,
|
||||
hasChildren,
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable header fields that distinguish one session lifecycle from another under the same id. */
|
||||
const LIFECYCLE_WITNESS_KEYS = [
|
||||
'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Whether an inspected log still belongs to the enumerated lifecycle,
|
||||
* mirroring the retired query-source compatibility check's field set.
|
||||
*/
|
||||
function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean {
|
||||
return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key])
|
||||
}
|
||||
|
||||
/** Stop a listing at its next cancellation checkpoint. */
|
||||
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one session-query operation between cancellation checkpoints. Query
|
||||
* implementations may reject with their own abort error after observing the
|
||||
* forwarded signal; cancellation remains a stable subagent failure.
|
||||
*/
|
||||
async function runListingQuery<T>(
|
||||
operation: () => Promise<T>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<T> {
|
||||
assertListingNotCancelled(signal)
|
||||
try {
|
||||
const result = await operation()
|
||||
assertListingNotCancelled(signal)
|
||||
return result
|
||||
} catch (error: unknown) {
|
||||
assertListingNotCancelled(signal)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a per-child query failure to a fixed diagnostic. Configuration errors
|
||||
* and unrecognized failures remain operation failures.
|
||||
*/
|
||||
function perChildDiagnosticReason(
|
||||
error: unknown,
|
||||
SessionQueryError: SessionQueryRuntime['SessionQueryError'],
|
||||
): 'corrupt' | 'unavailable' | undefined {
|
||||
if (!(error instanceof SessionQueryError)) return undefined
|
||||
switch (error.code) {
|
||||
case 'SESSION_QUERY_CORRUPT_SESSION':
|
||||
return 'corrupt'
|
||||
case 'SESSION_QUERY_SESSION_NOT_FOUND':
|
||||
case 'SESSION_QUERY_EVENT_NOT_FOUND':
|
||||
case 'SESSION_QUERY_PERSISTENCE_FAILED':
|
||||
return 'unavailable'
|
||||
case 'SESSION_QUERY_INVALID_SURFACE':
|
||||
case 'SESSION_QUERY_SOURCE_CONFLICT':
|
||||
return 'corrupt'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,48 @@ export interface SubagentTimingProjection {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable identity of one descriptor-backed subagent session: lifecycle mode
|
||||
* plus creation label, folded last-wins from `subagent/descriptor` events.
|
||||
* Label strength follows the descriptor schema: a continuable child always
|
||||
* carries one, a one-shot child may omit it.
|
||||
*/
|
||||
export type SubagentIdentityProjection =
|
||||
| {
|
||||
/** A terminal one-shot child. */
|
||||
mode: 'one-shot'
|
||||
/** Optional durable creation label from the child's descriptor. */
|
||||
label?: string
|
||||
/**
|
||||
* Seq of the `subagent/descriptor` event this identity was folded from.
|
||||
* `seq >= header.seedLength` proves the identity comes from the child's
|
||||
* OWN log suffix — where a descriptor is immutable once appended — and
|
||||
* not from a fork seed's replayed ancestor descriptor.
|
||||
*/
|
||||
seq: number
|
||||
}
|
||||
| {
|
||||
/** A resumable conversation. */
|
||||
mode: 'continuable'
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
label: string
|
||||
/** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Active-turn duration for a descriptor-backed subagent session. */
|
||||
subagentTiming: SubagentTimingProjection
|
||||
/**
|
||||
* Identity of a descriptor-backed subagent session. `null` ⟺ no valid
|
||||
* descriptor (missing, malformed, or unrecognized-version — deliberately
|
||||
* undistinguished). The sentinel is deliberately serializable: a
|
||||
* value pushed over JSON transports must survive `JSON.stringify`
|
||||
* losslessly, where an `undefined` field would be dropped and a stale
|
||||
* identity would survive on the receiving side. The entry itself stays
|
||||
* non-optional.
|
||||
*/
|
||||
subagent: SubagentIdentityProjection | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* Pure session projection for subagent active-turn duration.
|
||||
* Pure session projections for subagent identity (mode/label) and active-turn
|
||||
* duration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SubagentTimingProjection } from './projection-types.ts'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
import type { SubagentDescriptorData } from './descriptor.ts'
|
||||
import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
interface TimingState {
|
||||
/** Milliseconds accumulated across completed post-descriptor turns. */
|
||||
@@ -80,3 +84,73 @@ ProjectionDefinition<'subagentTiming', TimingState> = {
|
||||
}),
|
||||
stateVersion: 2,
|
||||
}
|
||||
|
||||
interface IdentityState {
|
||||
/** Identity from the last valid descriptor; absent before one, and after an invalid one. */
|
||||
identity?: SubagentIdentityProjection
|
||||
}
|
||||
|
||||
// The cast bridges only the optional-label arm: Zod's optional output
|
||||
// includes explicit `undefined`, which exactOptionalPropertyTypes excludes
|
||||
// from the public interface. The no-value state itself is the serializable
|
||||
// `null` arm — never `undefined` — so every registry read and push frame
|
||||
// survives JSON.stringify losslessly.
|
||||
const identitySchema = z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
mode: z.literal('one-shot'),
|
||||
label: z.string().optional(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
z.object({
|
||||
mode: z.literal('continuable'),
|
||||
label: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
|
||||
|
||||
/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
|
||||
function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
|
||||
let descriptor: SubagentDescriptorData | undefined
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([event])
|
||||
} catch {
|
||||
// Only a malformed current-version payload throws in descriptor parsing;
|
||||
// a projection fold must never throw, so damage folds to no value.
|
||||
descriptor = undefined
|
||||
}
|
||||
if (descriptor === undefined) return undefined
|
||||
return descriptor.mode === 'one-shot'
|
||||
? {
|
||||
mode: 'one-shot',
|
||||
...descriptor.label !== undefined ? { label: descriptor.label } : {},
|
||||
seq: event.seq,
|
||||
}
|
||||
: { mode: 'continuable', label: descriptor.label, seq: event.seq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the durable mode/label identity from `subagent/descriptor` events,
|
||||
* last-wins: a fork seed may replay an ancestor's descriptor, and the child's
|
||||
* own descriptor must override it — the same reset discipline as
|
||||
* {@link subagentTimingProjectionDefinition}. A malformed or unknown-version
|
||||
* payload resets to the `null` sentinel instead of throwing, so a fork of a
|
||||
* healthy ancestor never inherits an identity its own descriptor failed to
|
||||
* establish — and the reset survives every JSON push frame, so a consumer
|
||||
* holding the earlier identity replaces it instead of keeping it stale;
|
||||
* `null` ⟺ no valid descriptor, with the causes deliberately undistinguished.
|
||||
*/
|
||||
export const subagentIdentityProjectionDefinition:
|
||||
ProjectionDefinition<'subagent', IdentityState> = {
|
||||
key: 'subagent',
|
||||
schema: identitySchema,
|
||||
init: () => ({}),
|
||||
apply: (state, event) => {
|
||||
if (event.type !== 'subagent/descriptor') return state
|
||||
const identity = descriptorIdentity(event)
|
||||
return identity === undefined ? {} : { identity }
|
||||
},
|
||||
view: state => state.identity ?? null,
|
||||
// Bumped when the identity gained its `seq` field: an older checkpoint row
|
||||
// would replay into a value the schema rejects, so it must refold instead.
|
||||
stateVersion: 2,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -9,7 +10,12 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import SubagentService, {
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
SubagentError,
|
||||
@@ -17,7 +23,6 @@ import SubagentService, {
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
@@ -26,18 +31,29 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the continuable stack plus a concrete session-query service. */
|
||||
async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
|
||||
/** Boot the continuable stack with real JSONL session persistence. */
|
||||
async function setup(
|
||||
script: Script,
|
||||
options: { sessionProjections?: boolean; projectionCache?: boolean } = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
|
||||
if (options.projectionCache === true) {
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool()))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
}
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
@@ -101,38 +117,76 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
|
||||
return { version, mode: 'continuable' as const, provider: 'spawn', label }
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Test-only hostile probe proving per-child isolation of foreign unit failures. */
|
||||
subagentListHostileProbe: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreign registered unit that rejects one specific child's log at view
|
||||
* time: `apply` never throws (the eager drive passes every committed event
|
||||
* through it), while the poisoned state detonates only when a listing read
|
||||
* folds or serves this child through the registry.
|
||||
*/
|
||||
const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = {
|
||||
key: 'subagentListHostileProbe',
|
||||
schema: z.null(),
|
||||
init: () => ({}),
|
||||
apply: (state, event) =>
|
||||
event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me'
|
||||
? { poisoned: true }
|
||||
: state,
|
||||
view: (state) => {
|
||||
if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log')
|
||||
return null
|
||||
},
|
||||
stateVersion: 1,
|
||||
}
|
||||
|
||||
describe('SubagentService.listChildren', () => {
|
||||
it('lists through session query without the Activation continuation runtime', async () => {
|
||||
it('lists live children without persistence, query services, or the continuation runtime', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
expect(ctx.get('tasks')).toBeUndefined()
|
||||
expect(ctx.get('agents')).toBeUndefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeUndefined()
|
||||
|
||||
const parentId = SessionId('query-only-parent')
|
||||
const parentId = SessionId('live-only-parent')
|
||||
ctx.sessions.create(parentId)
|
||||
const childId = SessionId('query-only-child')
|
||||
const childId = SessionId('live-only-child')
|
||||
const child = ctx.sessions.create(childId, {
|
||||
meta: { parentSession: parentId, origin: 'subagent' },
|
||||
})
|
||||
child.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
child.append('subagent/descriptor', descriptorPayload('query-only child'))
|
||||
child.append('subagent/descriptor', descriptorPayload('live-only child'))
|
||||
|
||||
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
|
||||
{
|
||||
kind: 'child', id: childId, label: 'query-only child', mode: 'continuable',
|
||||
kind: 'child', id: childId, label: 'live-only child', mode: 'continuable',
|
||||
activity: 'running', hasChildren: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud before any work when session query is not loaded', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
it('fails loud when the projection registry is not mounted, even with no children', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionProjections: false })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loud when the session store is not mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.subagents.listChildren(SessionId('no-store-parent'))).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -148,7 +202,7 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('lists one-shot and continuable children from the same trace', async () => {
|
||||
it('lists one-shot and continuable children under the same parent', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
|
||||
const oneShot = await ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'finish once' }],
|
||||
@@ -205,33 +259,59 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders children by createdAt then id without inspecting ordinary forks', async () => {
|
||||
it('orders children by createdAt then id without listing ordinary forks', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Authored headers pin the ordering key deterministically: same createdAt
|
||||
// ties break on id, different createdAt orders ascending.
|
||||
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 9,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('late child')))
|
||||
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 5,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('tie b')))
|
||||
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 5,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('tie a')))
|
||||
/** Publish one live child with a pinned header ordering key. */
|
||||
const liveChild = (parentId: SessionId, id: string, createdAt: number, label: string): SessionId => {
|
||||
const session = ctx.sessions.create(SessionId(id), {
|
||||
meta: { parentSession: parentId, origin: 'subagent', createdAt },
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('subagent/descriptor', descriptorPayload(label))
|
||||
return session.header.id
|
||||
}
|
||||
// Live creation order is deliberately shuffled against the expected
|
||||
// result: same-createdAt ties break on id, different createdAt orders
|
||||
// ascending.
|
||||
const late = liveChild(parent.id, '00000000-0000-4000-8000-000000000009', 9, 'late child')
|
||||
const tieB = liveChild(parent.id, '00000000-0000-4000-8000-000000000002', 5, 'tie b')
|
||||
const tieA = liveChild(parent.id, '00000000-0000-4000-8000-000000000001', 5, 'tie a')
|
||||
// An ordinary session fork shares parentSession but has no subagent origin.
|
||||
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
|
||||
await ctx.sessions.flush(fork)
|
||||
const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents')
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
|
||||
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
|
||||
expect(listEvents).not.toHaveBeenCalledWith(fork.id)
|
||||
})
|
||||
|
||||
it('omits a live child that has not appended its descriptor yet', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const pending = ctx.sessions.create(SessionId('creation-window-child'), {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
pending.append('turn/start', { turn: 1 })
|
||||
// The creation window: the establishing provider has not appended the
|
||||
// descriptor yet, so the row is omitted rather than diagnosed.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('lists a one-shot child with its durable creation label', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'one-shot',
|
||||
provider: 'spawn',
|
||||
label: 'labeled one-shot',
|
||||
}))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
|
||||
{
|
||||
kind: 'child', id: labeled, mode: 'one-shot', label: 'labeled one-shot',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a live child as running while keeping settled siblings complete', async () => {
|
||||
@@ -256,7 +336,7 @@ describe('SubagentService.listChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
|
||||
it('lists the last descriptor when a log carries more than one', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const events = childEvents(descriptorPayload('twice'))
|
||||
@@ -267,22 +347,169 @@ describe('SubagentService.listChildren', () => {
|
||||
data: descriptorPayload('twice again'),
|
||||
} as SessionEvent)
|
||||
events[4] = { ...events[4]!, seq: 4 }
|
||||
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
|
||||
const doubled = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
// The last-wins projection fold serves the final descriptor's identity; a
|
||||
// repeated descriptor is not a per-child corruption diagnostic.
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: doubled, label: 'twice again', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => {
|
||||
it('serves the serializable null sentinel when a later descriptor invalidates the identity', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// The surface-eligible user/message lacks its required surfaceOp. The
|
||||
// first-party persistence inspection rejects before session-query can fold it.
|
||||
const liveId = SessionId('invalidated-live-child')
|
||||
const live = ctx.sessions.create(liveId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
live.append('turn/start', { turn: 1 })
|
||||
live.append('subagent/descriptor', descriptorPayload('was valid'))
|
||||
expect(ctx.sessionProjections.snapshot(live).values.subagent)
|
||||
.toEqual({ mode: 'continuable', label: 'was valid', seq: 1 })
|
||||
// Last-wins: the malformed follow-up resets the identity to the sentinel.
|
||||
live.append(
|
||||
'subagent/descriptor',
|
||||
{ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 } as never,
|
||||
)
|
||||
const values = ctx.sessionProjections.snapshot(live).values
|
||||
expect(values.subagent).toBeNull()
|
||||
// The sentinel survives a JSON push frame; an undefined field would be
|
||||
// dropped there and a consumer would keep the stale identity forever.
|
||||
const wired = JSON.parse(JSON.stringify(values)) as Record<string, unknown>
|
||||
expect('subagent' in wired).toBe(true)
|
||||
expect(wired['subagent']).toBeNull()
|
||||
// The listing reads the same null as no value: running → omitted.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('diagnoses a settled child whose later descriptor invalidated the identity as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const events = childEvents(descriptorPayload('was valid'))
|
||||
events.splice(3, 0, {
|
||||
type: 'subagent/descriptor',
|
||||
seq: 3,
|
||||
time: 3,
|
||||
data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 },
|
||||
} as SessionEvent)
|
||||
events[4] = { ...events[4]!, seq: 4 }
|
||||
const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
|
||||
{ kind: 'diagnostic', id: invalidated, reason: 'corrupt' },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves a cached own-suffix identity directly without inspection', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const child = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('disk label')))
|
||||
// seq 2 >= seedLength 0: the cached identity provably comes from the
|
||||
// child's own suffix, so it is final and the log is never re-read — the
|
||||
// divergent label proves the row, not the log, produced the entry.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({
|
||||
asOfSeq: 2,
|
||||
values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: child, label: 'cached own', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a cached ancestor identity from the fork seed and lets preparation rule', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
// A fork child: the seed replays the ancestor's descriptor (seq 2), and
|
||||
// the child's own descriptor arrives in its first own turn (seq 5).
|
||||
const seed = childEvents(descriptorPayload('ancestor label'))
|
||||
const events = [
|
||||
...seed,
|
||||
{ type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', {
|
||||
parentSession: parent.id,
|
||||
seedLength: seed.length,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
// A creation-window checkpoint carried the ANCESTOR identity: its seq 2
|
||||
// fails the own-suffix gate (< seedLength 4), so preparation rules.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({
|
||||
asOfSeq: 2,
|
||||
values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['version', (meta: SessionHeader): SessionHeader => ({ ...meta, version: meta.version + 1 })],
|
||||
['id', (meta: SessionHeader): SessionHeader => ({ ...meta, id: SessionId('another-lifecycle') })],
|
||||
['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })],
|
||||
['cwd', (meta: SessionHeader): SessionHeader => ({ ...meta, cwd: '/elsewhere' })],
|
||||
['parentSession', (meta: SessionHeader): SessionHeader => ({ ...meta, parentSession: SessionId('another-parent') })],
|
||||
['seedLength', (meta: SessionHeader): SessionHeader => ({ ...meta, seedLength: (meta.seedLength ?? 0) + 1 })],
|
||||
['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })],
|
||||
] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const reborn = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae03', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('reborn child')))
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
|
||||
const result = await original(sessionId, signal)
|
||||
if (sessionId !== reborn) return result
|
||||
// The id was re-published as a different lifecycle after enumeration.
|
||||
return { ...result, meta: mutate(result.meta) }
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets preparation rule when the cache serves the null sentinel', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('actually valid')))
|
||||
// A stale cached sentinel must not out-rank the authoritative re-fold.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maps a child rejected by persistence inspection to unavailable', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// The surface-eligible user/message lacks its required surfaceOp, so the
|
||||
// first-party inspection rejects before any projection fold can run.
|
||||
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
@@ -297,7 +524,7 @@ describe('SubagentService.listChildren', () => {
|
||||
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
|
||||
] as SessionEvent[])
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }])
|
||||
})
|
||||
|
||||
it('diagnoses a malformed descriptor payload as corrupt', async () => {
|
||||
@@ -310,28 +537,36 @@ describe('SubagentService.listChildren', () => {
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses an unknown descriptor version as unsupported', async () => {
|
||||
it('diagnoses an unknown descriptor version as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
|
||||
// The projection fold does not distinguish an unrecognized version from
|
||||
// other invalid descriptors: both serve no identity, and a settled
|
||||
// no-value candidate is corrupt.
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
|
||||
it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// A fork child whose seed replays a parent log containing a descriptor:
|
||||
// the seed's descriptor is the ANCESTOR's, not this child's.
|
||||
// The last-wins fold serves a seed-replayed ancestor descriptor until the
|
||||
// child's own descriptor overrides it (known deviation #1 in the design).
|
||||
const seed = childEvents(descriptorPayload('ancestor label'))
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
|
||||
const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
|
||||
parentSession: parent.id,
|
||||
seedLength: seed.length,
|
||||
origin: 'subagent',
|
||||
}, seed)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([])
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
|
||||
@@ -354,103 +589,85 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
|
||||
it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'flaky storage')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
query.listEvents = (sessionId) => {
|
||||
if (sessionId === childId) {
|
||||
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
}
|
||||
return originalListEvents(sessionId)
|
||||
}
|
||||
ctx.sessionProjections.register(hostileProjectionDefinition)
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('poison me')))
|
||||
// The subagent unit itself folds this child cleanly; the FOREIGN unit's
|
||||
// view throws, and that damage stays contained to the one child.
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['session', 'SESSION_QUERY_SESSION_NOT_FOUND'],
|
||||
['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'],
|
||||
] as const)('maps a missing child %s to unavailable', async (_target, code) => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'vanishing child')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('gone', code))
|
||||
it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
ctx.sessionProjections.register(hostileProjectionDefinition)
|
||||
const poisonedId = SessionId('live-poisoned-child')
|
||||
const poisoned = ctx.sessions.create(poisonedId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
poisoned.append('turn/start', { turn: 1 })
|
||||
poisoned.append('subagent/descriptor', descriptorPayload('poison me'))
|
||||
const healthyId = SessionId('live-healthy-child')
|
||||
const healthy = ctx.sessions.create(healthyId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
healthy.append('turn/start', { turn: 1 })
|
||||
healthy.append('subagent/descriptor', descriptorPayload('live healthy'))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable',
|
||||
activity: 'running', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an invalid child surface to corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'invalid surface')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'reparented child')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
query.readEvent = async (request) => {
|
||||
const window = await originalReadEvent(request)
|
||||
return {
|
||||
...window,
|
||||
session: { ...window.session, parentSession: SessionId('someone-else') },
|
||||
}
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
// The exact read's conflicting immutable header is per-child corruption.
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'shifted log')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
query.readEvent = async (request) => {
|
||||
const window = await originalReadEvent(request)
|
||||
return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('fails the whole call when the initial trace fails', async () => {
|
||||
it('fails the whole enumeration when the persisted listing itself fails', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'never listed')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.traceSession = () =>
|
||||
Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
|
||||
)
|
||||
ctx.sessionPersistence.list = () => Promise.reject(new Error('backend listing failed'))
|
||||
// Without any abort in flight, the original backend failure propagates
|
||||
// as the operation failure — no cancellation mapping, no diagnostic rows.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('backend listing failed')
|
||||
})
|
||||
|
||||
it('propagates an unrecognized per-child failure as an operation failure', async () => {
|
||||
it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'strange failure')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => Promise.reject(new Error('not a query failure'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
|
||||
})
|
||||
|
||||
it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'misconfigured query')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
|
||||
)
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const flaky = await authorChild(ctx, '00000000-0000-4000-8000-00000000f1a7', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('flaky storage')))
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = (sessionId, signal) => {
|
||||
if (sessionId === flaky) {
|
||||
return Promise.reject(new Error('backend read failed'))
|
||||
}
|
||||
return original(sessionId, signal)
|
||||
}
|
||||
// Per-child isolation: the failed child degrades to one diagnostic while
|
||||
// the healthy sibling stays complete.
|
||||
const degraded = await ctx.subagents.listChildren(parent.id)
|
||||
expect(degraded).toContainEqual({ kind: 'diagnostic', id: flaky, reason: 'unavailable' })
|
||||
expect(degraded).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
// Nothing is memoized: with the backend healthy again, the next listing
|
||||
// folds the same child to its identity.
|
||||
ctx.sessionPersistence.inspect = original
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({
|
||||
kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists compacted and uncompacted children identically', async () => {
|
||||
@@ -492,19 +709,18 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an origin-classified grandchild without reading its events', async () => {
|
||||
it('reports an origin-classified grandchild without inspecting it', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'direct child')
|
||||
const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
|
||||
parentSession: childId,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('grandchild')))
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
const inspected: SessionId[] = []
|
||||
query.listEvents = (sessionId) => {
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = (sessionId, signal) => {
|
||||
inspected.push(sessionId)
|
||||
return originalListEvents(sessionId)
|
||||
return original(sessionId, signal)
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
@@ -513,10 +729,112 @@ describe('SubagentService.listChildren', () => {
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
])
|
||||
// The grandchild contributes only its header to the hasChildren hint.
|
||||
expect(inspected).toContain(childId)
|
||||
expect(inspected).not.toContain(grandchildId)
|
||||
})
|
||||
|
||||
it('inspects each cold child exactly once and a live child never', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const coldStarted = await startChild(ctx, parent, 'cold started child')
|
||||
const coldAuthored = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('cold authored child')))
|
||||
const liveId = SessionId('live-mixed-child')
|
||||
const live = ctx.sessions.create(liveId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
live.append('turn/start', { turn: 1 })
|
||||
live.append('subagent/descriptor', descriptorPayload('live mixed child'))
|
||||
|
||||
const inspected: SessionId[] = []
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = (sessionId, signal) => {
|
||||
inspected.push(sessionId)
|
||||
return original(sessionId, signal)
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toHaveLength(3)
|
||||
// The cost model: one inspection per cold child, none for a live child,
|
||||
// whose identity is served from the registry's watermark cache.
|
||||
expect(inspected.filter(id => id === coldStarted)).toHaveLength(1)
|
||||
expect(inspected.filter(id => id === coldAuthored)).toHaveLength(1)
|
||||
expect(inspected).not.toContain(liveId)
|
||||
})
|
||||
|
||||
it('serves a cold child from the projection cache without any inspection', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')], { projectionCache: true })
|
||||
const childId = await startChild(ctx, parent, 'cached child')
|
||||
// The child's turn/end and disposal are the cache's mandatory checkpoint
|
||||
// points; both writes are fail-soft asynchronous, so wait for the row.
|
||||
const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
|
||||
}, { timeout: 5_000 })
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: childId, label: 'cached child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to inspection when the cache serves no identity for the child', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('uncached child')))
|
||||
const expected = [{
|
||||
kind: 'child', id: foreign, label: 'uncached child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}]
|
||||
// No stored row at all for a foreign child this process never ran.
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
// A stored row whose cut predates the descriptor: the subagent key is
|
||||
// absent from the served values, and preparation still rules.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
|
||||
expect(inspect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('takes the preparation rung directly when no projection cache is mounted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(ctx.get('sessionProjectionCache')).toBeUndefined()
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('uncacheable child')))
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('silently falls through to preparation when the cache read throws', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const recovered = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac03', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('recovered child')))
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => {
|
||||
// A poisoned stored row (any unit's) detonates at view time; the cache
|
||||
// is derived data, so its failure must not become a verdict.
|
||||
throw new Error('poisoned cache row')
|
||||
}
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not count an ordinary grandchild without subagent origin', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'direct child')
|
||||
@@ -550,115 +868,92 @@ describe('SubagentService.listChildren', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('one'), textResponse('two')])
|
||||
await startChild(ctx, parent, 'first child')
|
||||
await startChild(ctx, parent, 'second child')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
let inspected = 0
|
||||
query.listEvents = (sessionId) => {
|
||||
inspected += 1
|
||||
// Cancel while the first candidate's read is in flight: the loop's next
|
||||
// between-candidates checkpoint must stop before the second read.
|
||||
controller.abort()
|
||||
return originalListEvents(sessionId)
|
||||
}
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
expect(inspected).toBe(1)
|
||||
})
|
||||
|
||||
it('forwards cancellation to the initial trace and reports the stable subagent error', async () => {
|
||||
it('a pre-aborted signal stops before any persistence read', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
query.traceSession = (_sessionId, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('query trace aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'cancelled exact read')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
query.readEvent = (_request, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('query read aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('stops after a per-child read when the signal aborts mid-inspection', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'cancelled mid-read')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
let exactReads = 0
|
||||
query.readEvent = async (request) => {
|
||||
exactReads += 1
|
||||
const window = await originalReadEvent(request)
|
||||
controller.abort()
|
||||
return window
|
||||
}
|
||||
// The post-read checkpoint throws a subagent error, which is not a
|
||||
// session-query failure and therefore propagates instead of becoming a
|
||||
// per-child diagnostic.
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error)
|
||||
expect(exactReads).toBe(1)
|
||||
})
|
||||
|
||||
it('a mapped per-child failure during an abort cannot become a successful result', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'aborted behind a diagnostic')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => {
|
||||
// The read fails with a diagnostic-mapped code while the caller aborts:
|
||||
// cancellation normalization must fail the scan rather than return a
|
||||
// one-diagnostic success.
|
||||
controller.abort()
|
||||
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
}
|
||||
ctx.sessionPersistence.list = () => Promise.reject(new Error('must not be called'))
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('a pre-aborted signal stops before any candidate read', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'never read')
|
||||
it('forwards cancellation to the persisted listing and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.list = (signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('backend listing aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => Promise.reject(new Error('must not be called'))
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards cancellation to a cold inspection and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-00000000ce11', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('cancelled cold read')))
|
||||
const controller = new AbortController()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.inspect = (_sessionId, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('backend read aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('an abort observed after a cold inspection resolves cannot become a successful result', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-00000000ce12', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('cancelled mid-listing')))
|
||||
const controller = new AbortController()
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
|
||||
const result = await original(sessionId, signal)
|
||||
controller.abort()
|
||||
return result
|
||||
}
|
||||
// The post-read checkpoint throws the stable subagent error instead of
|
||||
// interpreting the fully-read log as a successful listing.
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error)
|
||||
})
|
||||
|
||||
it('a cold inspection failure during an abort cannot become an unavailable diagnostic', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-00000000ce13', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('aborted behind a failure')))
|
||||
const controller = new AbortController()
|
||||
ctx.sessionPersistence.inspect = () => {
|
||||
// The read fails while the caller aborts: cancellation normalization
|
||||
// must fail the listing rather than return a one-diagnostic success.
|
||||
controller.abort()
|
||||
return Promise.reject(new Error('backend read failed'))
|
||||
}
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
@@ -671,9 +966,9 @@ describe('SubagentService.listChildren', () => {
|
||||
})
|
||||
|
||||
it('SubagentError from listChildren is typed with its stable code', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
const { ctx, parent } = await setup([], { sessionProjections: false })
|
||||
const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
|
||||
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('@deepseek-ai/dsh-subagent optional session-query peer', () => {
|
||||
it('loads ordinary subagent operations without evaluating the optional query package', async () => {
|
||||
vi.doMock('@deepseek-ai/dsh-session-query', () => {
|
||||
throw new Error('optional session-query runtime was loaded eagerly')
|
||||
})
|
||||
|
||||
const subagent = await import('../src/index.ts')
|
||||
|
||||
expect(subagent.SubagentService).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -23,11 +23,15 @@ describe('subagent timing projection', () => {
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const serviceFiber = await ctx.plugin(SubagentService)
|
||||
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toEqual({ settledMs: 0 })
|
||||
const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
|
||||
expect(before.subagentTiming).toEqual({ settledMs: 0 })
|
||||
// The identity unit registers alongside timing; an empty log serves its
|
||||
// serializable null sentinel.
|
||||
expect(before.subagent).toBeNull()
|
||||
await serviceFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toBeUndefined()
|
||||
const after = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
|
||||
expect(after.subagentTiming).toBeUndefined()
|
||||
expect(after.subagent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
|
||||
@@ -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/subagent/tool-subagent-control/README.md
|
||||
README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca
|
||||
README.zh.md: 3b989fca8b79cea3e3b10bb2e65805e0cee79c69
|
||||
README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac
|
||||
README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
|
||||
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
|
||||
|
||||
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message` 而省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
|
||||
|
||||
本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
|
||||
|
||||
@@ -33,16 +33,10 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
@@ -52,7 +46,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
/**
|
||||
* The globally named `list_agents` tool: a thin model-facing adapter over
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It is
|
||||
* separately loadable from the
|
||||
* root `send_message` plugin because it additionally requires the session
|
||||
* query service — a deployment may use `send_message` without loading session
|
||||
* query, and this plugin remains inactive until that service is available.
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It stays
|
||||
* separately loadable from the root `send_message` plugin so a deployment
|
||||
* can register `send_message` without exposing the list tool.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-query'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent-list-agents'
|
||||
export const inject = ['tools', 'subagents', 'sessionQuery']
|
||||
export const inject = ['tools', 'subagents']
|
||||
|
||||
type ListAgentsEntry =
|
||||
| {
|
||||
@@ -31,7 +28,7 @@ type ListAgentsEntry =
|
||||
|
||||
/**
|
||||
* Register the `list_agents` tool.
|
||||
* @param ctx - context carrying the tool registry, subagent service, and session query.
|
||||
* @param ctx - context carrying the tool registry and subagent service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -8,11 +8,11 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
import * as tool from '../src/list-agents.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -29,9 +29,9 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
@@ -177,17 +177,16 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
|
||||
it('has the namespace-plugin export shape', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-list-agents')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
|
||||
expect(tool.inject).toEqual(['tools', 'subagents'])
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -27,6 +28,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(tool)
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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/todo/tool-todo/README.md
|
||||
README.md: 456d4a08d88b145d574362ffa0874faef9167b22
|
||||
README.zh.md: ec37682773e50c3f153525f6c2b6b6cce583144f
|
||||
README.md: 914e89a000e4bb87ebd7844f05db3809c6726528
|
||||
README.zh.md: c88dbf976fa5110028fcc964ab9aa8efcc3244d3
|
||||
|
||||
@@ -14,9 +14,15 @@ Registers one tool, `todo_write(todos: [{ content, status }])`, on `ctx.tools`.
|
||||
|
||||
The list belongs to the ONE agent session that called the tool. There is no subagent/shared/swarm scope: a non-agent caller (no `exec.agent`) has nowhere to write the list and is rejected. This is a deliberate scope limit — see the Agent Note.
|
||||
|
||||
## Configuration
|
||||
|
||||
`allowParallelInProgress` is required: every composition must choose whether several todos may be `in_progress` at once. It is a deployment choice, not a fixed rule: whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe. Use `true` for agents that may fan out work and `false` to enforce the single-active discipline.
|
||||
|
||||
The flag moves the model-facing instruction and the accepted input together — `true` asks the model to mark every actively worked task and accepts any number, `false` asks for exactly one and rejects a call marking more with `Error: invalid todos: at most one task may be in_progress (got <n>)`. The durable-log invariant does NOT follow it: a log written while parallel work was allowed must still replay after a deployment tightens the policy, so the invariant stays silent on the active count.
|
||||
|
||||
## Validation
|
||||
|
||||
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, more than one `in_progress` task (a coherent plan has at most one task active), and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. How many tasks may be `in_progress` at once is the deployment's call (§ Configuration): a composition that chooses `true` permits parallel work (concurrent subagents, background commands) to mark several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
|
||||
## Rendering
|
||||
|
||||
@@ -50,7 +56,7 @@ Prefix-stable while the definition and visibility are unchanged. Plugin lifecycl
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content "<content>"`, `Error: invalid todos: at most one task may be in_progress, got <count>`, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message.
|
||||
Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content "<content>"`, `Error: todo_write requires an owning agent session`, and — only where the deployment set `allowParallelInProgress: false` — `Error: invalid todos: at most one task may be in_progress (got <n>)`. The full `todo/write` session event is UI and replay state, not a second model message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
|
||||
该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note(agent 决策记录)。
|
||||
|
||||
## 配置
|
||||
|
||||
`allowParallelInProgress` 是必填项:每个组合都必须选择是否允许多个 todo 同时处于 `in_progress`。这是部署层的选择而非固定规则:并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况。可能并行展开工作的 agent 使用 `true`,`false` 则强制执行单活跃项纪律。
|
||||
|
||||
该开关会同时改变面向模型的指令与接受的输入——`true` 要求模型标记每个正在推进的任务并接受任意数量;`false` 要求恰好一个,并以 `Error: invalid todos: at most one task may be in_progress (got <n>)` 拒绝标记更多的调用。持久日志不变式**不**跟随它:在允许并行时写下的日志,在部署收紧策略之后仍必须可回放,因此不变式对活跃数量保持沉默。
|
||||
|
||||
## 验证
|
||||
|
||||
除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会明确报错而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。列表的顺序及及时更新由模型依照工具描述负责。
|
||||
除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会明确报错而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。同时可以有多少任务处于 `in_progress` 由部署决定(见 § 配置):选择 `true` 的组合允许并行工作(并发 subagent、后台命令)同时将多个任务标记为 `in_progress`。列表的顺序及及时更新由模型依照工具描述负责。
|
||||
|
||||
## 渲染
|
||||
|
||||
@@ -50,7 +56,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每个 assistant 工具调用都会在参数中保留整个替换列表。成功时原样返回 `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content "<content>"`、`Error: invalid todos: at most one task may be in_progress, got <count>` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。
|
||||
每个 assistant 工具调用都会在参数中保留整个替换列表。成功时原样返回 `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content "<content>"`、`Error: todo_write requires an owning agent session`,以及——仅在部署设置了 `allowParallelInProgress: false` 时——`Error: invalid todos: at most one task may be in_progress (got <n>)`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -41,6 +42,8 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import z from 'schemastery'
|
||||
import { z as zod } from 'zod'
|
||||
import type { ZodType } from 'zod'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
||||
@@ -24,29 +25,73 @@ export const inject = ['tools']
|
||||
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
|
||||
const STATUSES = ['pending', 'in_progress', 'completed'] as const
|
||||
|
||||
const DESCRIPTION =
|
||||
/** Model-facing todo tool configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Required deployment choice for whether several todos may be `in_progress` at once. True suits
|
||||
* agents that run work concurrently — subagents, background commands, workflow fan-out — and the
|
||||
* description then instructs the model to mark every actively worked task. False restores the
|
||||
* single-active discipline: the description asks for exactly one, and a call marking more is
|
||||
* rejected.
|
||||
*/
|
||||
allowParallelInProgress: boolean
|
||||
}
|
||||
|
||||
/** Schemastery configuration for the todo tool consumer. */
|
||||
export const Config: z<Config> = z.object({
|
||||
allowParallelInProgress: z.boolean().required(),
|
||||
})
|
||||
|
||||
const DESCRIPTION_HEAD =
|
||||
'Record and update a structured task list for the current work. Send the ENTIRE '
|
||||
+ 'list every call — it REPLACES the previous list (there are no partial updates, '
|
||||
+ 'no per-item edits). Use it to plan multi-step work and show progress: add one '
|
||||
+ 'todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` '
|
||||
+ 'at a time; while work remains, exactly one active task should be '
|
||||
+ '`in_progress`. Mark a todo `completed` the moment it is done (do not batch '
|
||||
+ 'completions), and allow no `in_progress` item only once all work is complete. '
|
||||
+ 'Skip the list for trivial single-step tasks. Statuses: `pending` '
|
||||
+ '(not started), `in_progress` (being worked on now), `completed` (finished).'
|
||||
+ 'todo per concrete step before you start. '
|
||||
|
||||
const DESCRIPTION_PARALLEL =
|
||||
'Mark every todo being actively worked '
|
||||
+ 'on `in_progress` — several at once when work genuinely runs in parallel (e.g. '
|
||||
+ 'concurrent subagents or background commands), one for sequential work; while '
|
||||
+ 'work remains, at least one task should be `in_progress`. '
|
||||
|
||||
const DESCRIPTION_SINGLE =
|
||||
'Keep AT MOST ONE todo `in_progress` at a '
|
||||
+ 'time; while work remains, exactly one active task should be `in_progress`. '
|
||||
|
||||
const DESCRIPTION_TAIL =
|
||||
'Mark a todo '
|
||||
+ '`completed` the moment it is done (do not batch completions), and allow no '
|
||||
+ '`in_progress` item only once all work is complete. Skip the list for trivial '
|
||||
+ 'single-step tasks. Statuses: `pending` (not started), `in_progress` (being '
|
||||
+ 'worked on now), `completed` (finished).'
|
||||
|
||||
/**
|
||||
* The model-facing description for one activation. The active-status clause is the only part that
|
||||
* varies, because it is the only instruction the parallel policy changes.
|
||||
* @param allowParallel - whether several todos may be `in_progress` at once.
|
||||
* @returns the composed tool description.
|
||||
*/
|
||||
function describe(allowParallel: boolean): string {
|
||||
return DESCRIPTION_HEAD
|
||||
+ (allowParallel ? DESCRIPTION_PARALLEL : DESCRIPTION_SINGLE)
|
||||
+ DESCRIPTION_TAIL
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
|
||||
* TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry
|
||||
* has already enforced the status enum and rejected unknown item keys (`additionalProperties:
|
||||
* false` — the logged snapshot must equal what the model believes it wrote, so a nested/extended
|
||||
* item shape fails loud at the schema boundary instead of silently flattening); the cast below
|
||||
* records that guarantee.
|
||||
* TodoItem}[]: trimmed non-empty unique content, and at most one `in_progress` item unless the
|
||||
* deployment allows parallel work. The registry has already enforced the status enum and rejected
|
||||
* unknown item keys (`additionalProperties: false` — the logged snapshot must equal what the model
|
||||
* believes it wrote, so a nested/extended item shape fails loud at the schema boundary instead of
|
||||
* silently flattening); the cast below records that guarantee.
|
||||
* @param raw - the model-supplied list, already schema-checked.
|
||||
* @param allowParallel - whether several items may be `in_progress` at once.
|
||||
* @returns the canonical list.
|
||||
*/
|
||||
function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
|
||||
function toTodoList(raw: { content: string; status: string }[], allowParallel: boolean): TodoItem[] {
|
||||
const todos: TodoItem[] = []
|
||||
const seen = new Set<string>()
|
||||
let inProgress = 0
|
||||
let active = 0
|
||||
for (const item of raw) {
|
||||
const content = item.content.trim()
|
||||
if (content.length === 0) {
|
||||
@@ -56,27 +101,32 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
|
||||
throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`)
|
||||
}
|
||||
seen.add(content)
|
||||
const status = item.status as TodoItem['status']
|
||||
if (status === 'in_progress') inProgress++
|
||||
todos.push({ content, status })
|
||||
if (item.status === 'in_progress') active++
|
||||
todos.push({ content, status: item.status as TodoItem['status'] })
|
||||
}
|
||||
if (inProgress > 1) {
|
||||
throw new Error(`invalid todos: at most one task may be in_progress, got ${inProgress}`)
|
||||
if (!allowParallel && active > 1) {
|
||||
throw new Error(`invalid todos: at most one task may be in_progress (got ${active})`)
|
||||
}
|
||||
return todos
|
||||
}
|
||||
|
||||
/** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */
|
||||
const todosProjectionSchema: ZodType<TodoItem[] | null> = z.union([
|
||||
z.array(z.object({
|
||||
content: z.string(),
|
||||
status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
|
||||
const todosProjectionSchema: ZodType<TodoItem[] | null> = zod.union([
|
||||
zod.array(zod.object({
|
||||
content: zod.string(),
|
||||
status: zod.union([zod.literal('pending'), zod.literal('in_progress'), zod.literal('completed')]),
|
||||
})),
|
||||
z.null(),
|
||||
zod.null(),
|
||||
])
|
||||
|
||||
/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` unit. */
|
||||
export function apply(ctx: Context): void {
|
||||
/**
|
||||
* Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed,
|
||||
* the `todos` unit.
|
||||
* @param ctx - registrant context carrying the tool registry.
|
||||
* @param config - deployment's explicit todo policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const allowParallel = config.allowParallelInProgress
|
||||
// The unit child activates only when a projection registry is composed
|
||||
// (headless assemblies without the seam stay unaffected). Standing-plan fold:
|
||||
// latest whole todo/write list, cleared by the next turn/start (turn/end keeps
|
||||
@@ -99,7 +149,7 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'todo_write',
|
||||
description: DESCRIPTION,
|
||||
description: describe(allowParallel),
|
||||
parameters: {
|
||||
todos: {
|
||||
type: 'array',
|
||||
@@ -155,7 +205,7 @@ export function apply(ctx: Context): void {
|
||||
}],
|
||||
},
|
||||
execute(args, exec) {
|
||||
const todos = toTodoList(args.todos)
|
||||
const todos = toTodoList(args.todos, allowParallel)
|
||||
if (!exec.agent) {
|
||||
// The list is per-agent-session state; a non-agent caller (no owning
|
||||
// session) has nowhere to write it. Reject rather than silently no-op.
|
||||
|
||||
@@ -12,11 +12,18 @@ export const name = 'tool-todo-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate one whole-list todo snapshot before it reaches the durable log. */
|
||||
/**
|
||||
* Validate one whole-list todo snapshot before it reaches the durable log.
|
||||
*
|
||||
* Deliberately silent on how many items are `in_progress`. That is the tool's
|
||||
* per-deployment policy (`Config.allowParallelInProgress`), not a durable-shape
|
||||
* rule: a log written while parallel work was allowed must still replay after a
|
||||
* deployment tightens the policy, so tying the invariant to the current config
|
||||
* would reject history that was valid when it was written.
|
||||
*/
|
||||
function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
if (!Array.isArray(value)) fail('todo/write todos must be an array')
|
||||
const seen = new Set<string>()
|
||||
let active = 0
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'object' || item === null) fail('todo/write entries must be objects')
|
||||
const { content, status } = item as Record<string, unknown>
|
||||
@@ -28,9 +35,7 @@ function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
if (typeof status !== 'string' || !TODO_STATUSES.has(status)) {
|
||||
fail(`todo/write carries unknown status ${JSON.stringify(status)}`)
|
||||
}
|
||||
if (status === 'in_progress') active += 1
|
||||
}
|
||||
if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user